koala73/worldmonitor · error · ValidationError
must contain between 1 and ${MAX_BATCH_OPERATIONS} operation
Error message
must contain between 1 and ${MAX_BATCH_OPERATIONS} operations What it means
executeBatch throws ValidationError (HTTP 400 with a violations array) when req.operations is not an array of 1 to MAX_BATCH_OPERATIONS (20) entries. A missing or non-array operations field coerces to an empty array and hits the same check, so 'forgot the field' and 'too many operations' produce this one error. Unlike per-operation path problems (which become per-result errors), count violations reject the entire request.
Source
Thrown at server/worldmonitor/batch/v1/execute-batch.ts:199
}
export function createExecuteBatch(
fetchImpl: FetchLike = (input, init) => fetch(input, init),
) {
return async function executeBatch(
ctx: ServerContext,
req: ExecuteBatchRequest,
): Promise<ExecuteBatchResponse> {
// Recursion guard: the gateway forwards the marker untouched, so a batch
// arriving with it was issued BY a batch — refuse regardless of the
// per-path nested_batch check below.
if (ctx.request.headers.has(BATCH_MARKER_HEADER)) {
throw new ApiError(400, 'Nested batch requests are not allowed', '');
}
const operations = Array.isArray(req.operations) ? req.operations : [];
if (operations.length < 1 || operations.length > MAX_BATCH_OPERATIONS) {
throw new ValidationError([{
field: 'operations',
description: `must contain between 1 and ${MAX_BATCH_OPERATIONS} operations`,
}]);
}
const origin = new URL(ctx.request.url).origin;
const { validated, violations } = validateOperations(operations, origin);
if (violations.length > 0) {
throw new ValidationError(violations);
}
const headers = buildSubRequestHeaders(ctx.request.headers);
const results = await Promise.all(
validated.map((op) => runOperation(op, headers, fetchImpl)),
);
const succeeded = results.filter((r) => r.status >= 200 && r.status < 300 && !r.error).length;
return { results, succeeded, failed: results.length - succeeded };View on GitHub (pinned to eeab0a219f)
Solutions
- Keep each request between 1 and 20 operations
- Chunk larger sets client-side into multiple batch calls (respecting per-request rate limits)
- Guard before sending: Array.isArray(ops) && ops.length >= 1, and log the count when it is 0 to find upstream filtering bugs
Example fix
// before — one request with everything
await post('/api/batch/v1/execute', { operations: allOps }); // 350 ops -> 400
// after — chunked at the documented ceiling
const MAX_BATCH_OPERATIONS = 20;
for (let i = 0; i < allOps.length; i += MAX_BATCH_OPERATIONS) {
const chunk = allOps.slice(i, i + MAX_BATCH_OPERATIONS);
await post('/api/batch/v1/execute', { operations: chunk });
} Defensive patterns
Strategy: validation
Validate before calling
const MAX_BATCH_OPERATIONS = 20; // mirror of the server constant
function chunk<T>(items: T[], size = MAX_BATCH_OPERATIONS): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
if (!Array.isArray(ops) || ops.length === 0) throw new Error('nothing to batch'); Type guard
function isOperationsCountViolation(body: unknown): boolean {
const v = (body as { violations?: { field?: string }[] })?.violations;
return Array.isArray(v) && v.some((x) => x.field === 'operations');
} Try / catch
try {
await post('/api/batch/v1/execute', { operations: chunk(ops) });
} catch (e) {
if (e instanceof HttpError && e.status === 400 && isOperationsCountViolation(e.body)) {
// re-chunk smaller (<=20) and resend; empty lists are a caller bug
}
} Prevention
- Chunk at the documented ceiling (20) in every fan-out helper instead of assuming list sizes
- Assert operations.length > 0 before sending — a zero-length build is usually an upstream filter bug
- Keep the client constant in sync with the API docs; the server value is MAX_BATCH_OPERATIONS = 20
When it happens
Trigger: POST with operations: []; POST with 21+ operations in one request; operations sent as an object or omitted entirely (Array.isArray fails, treated as empty); a client building operations dynamically ends up with zero after filtering.
Common situations: Fan-out loop groups items into one oversized batch instead of chunks; JSON serialization bug drops the operations field; paginated caller batches 'all N items' without a ceiling.
Related errors
- Nested batch requests are not allowed
- must be at most ${MAX_OPERATION_ID_LENGTH} characters
- duplicate id "${id}" — results would be ambiguous
- get_intel_timeline requires at least one of domain ("conflic
- INCOMPATIBLE_DELIVERY
AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21).
Data as JSON: /api/errors/d933aba78cf754bd.
Report an issue: GitHub.