{"record":{"id":"847dc78ba3e49826","repo":"koala73/worldmonitor","slug":"nested-batch-requests-are-not-allowed","errorCode":null,"errorMessage":"Nested batch requests are not allowed","messagePattern":"Nested batch requests are not allowed","errorType":"http","errorClass":"ApiError","httpStatus":400,"severity":"error","filePath":"server/worldmonitor/batch/v1/execute-batch.ts","lineNumber":194,"sourceCode":"  } catch {\n    return { id: op.id, status: response.status, error: 'invalid_json' };\n  }\n\n  return { id: op.id, status: response.status, body: body as BatchOperationBody, error: '' };\n}\n\nexport function createExecuteBatch(\n  fetchImpl: FetchLike = (input, init) => fetch(input, init),\n) {\n  return async function executeBatch(\n    ctx: ServerContext,\n    req: ExecuteBatchRequest,\n  ): Promise<ExecuteBatchResponse> {\n    // Recursion guard: the gateway forwards the marker untouched, so a batch\n    // arriving with it was issued BY a batch — refuse regardless of the\n    // per-path nested_batch check below.\n    if (ctx.request.headers.has(BATCH_MARKER_HEADER)) {\n      throw new ApiError(400, 'Nested batch requests are not allowed', '');\n    }\n\n    const operations = Array.isArray(req.operations) ? req.operations : [];\n    if (operations.length < 1 || operations.length > MAX_BATCH_OPERATIONS) {\n      throw new ValidationError([{\n        field: 'operations',\n        description: `must contain between 1 and ${MAX_BATCH_OPERATIONS} operations`,\n      }]);\n    }\n\n    const origin = new URL(ctx.request.url).origin;\n    const { validated, violations } = validateOperations(operations, origin);\n    if (violations.length > 0) {\n      throw new ValidationError(violations);\n    }\n\n    const headers = buildSubRequestHeaders(ctx.request.headers);\n    const results = await Promise.all(","sourceCodeStart":176,"sourceCodeEnd":212,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/server/worldmonitor/batch/v1/execute-batch.ts#L176-L212","documentation":"executeBatch (POST /api/batch/v1/execute) refuses any inbound request that already carries the x-wm-batch marker header. Sub-requests dispatched by a batch get this header set by buildSubRequestHeaders, and the gateway forwards it untouched, so its presence proves the request was issued by another batch — batching a batch would multiply per-request resource use recursively. It is an ApiError 400 raised before any operation validation.","triggerScenarios":"A client (or test harness) that copies response/request headers wholesale from a previous batch call onto a new top-level batch request, including x-wm-batch: 1; a proxy that injects unknown x-* headers; manually curling the batch endpoint with -H 'x-wm-batch: 1'. Note: including a /api/batch/* path inside operations does NOT normally reach this throw — validateOperations marks that operation as a per-result error 'nested_batch' instead.","commonSituations":"SDK or fetch wrapper with default headers captured from an earlier sub-request; an integration test replaying recorded headers; an agent framework that forwards all inbound headers when proxying the API.","solutions":["Stop forwarding the x-wm-batch header in your HTTP client — only the server sets it on sub-requests","If you intended to run multiple batches, issue separate top-level POST /api/batch/v1/execute requests instead of nesting","If you meant to include another endpoint in a batch, use its documented /api/<domain>/v<N>/<rpc> path; it will run as a normal operation"],"exampleFix":"// before — headers object reused from a prior (server-dispatched) request\nconst resp = await fetch(`${origin}/api/batch/v1/execute`, {\n  method: 'POST',\n  headers: capturedHeaders, // contains x-wm-batch: 1 -> 400\n  body: JSON.stringify({ operations }),\n});\n\n// after — explicit allowlist of client headers only\nconst resp = await fetch(`${origin}/api/batch/v1/execute`, {\n  method: 'POST',\n  headers: { authorization: capturedHeaders.authorization, 'content-type': 'application/json' },\n  body: JSON.stringify({ operations }),\n});","handlingStrategy":"validation","validationCode":"// strip any batch marker and batch paths before submitting\nconst safeHeaders = new Headers(myHeaders); // never copy x-wm-batch from prior traffic\nsafeHeaders.delete('x-wm-batch');\nconst operations = ops.filter((o) => !o.path.startsWith('/api/batch/'));","typeGuard":"function isNestedBatchError(body: unknown): boolean {\n  return typeof body === 'object' && body !== null && 'message' in body\n    && (body as { message?: string }).message === 'Nested batch requests are not allowed';\n}","tryCatchPattern":"try {\n  await post('/api/batch/v1/execute', { operations });\n} catch (e) {\n  if (e instanceof HttpError && e.status === 400 && isNestedBatchError(e.body)) {\n    // fix the client's header forwarding; retrying unchanged will fail again\n  }\n}","preventionTips":["Use an explicit header allowlist (authorization, content-type) in your HTTP client instead of forwarding everything","Never include /api/batch/* paths in operations — they fail per-operation as 'nested_batch' anyway","In test harnesses, build headers from scratch rather than replaying recorded request headers"],"tags":["batch","recursion-guard","http-headers","validation"],"backgroundTag":"recursive-request-blocked","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}