Batch APIs and Partial Failure
A client that needs 500 resources can make 500 requests or one. The batch endpoint saves round trips and rate-limit budget — and forces the contract to answer questions a single request never asked: what if item 217 fails, is anything rolled back, and how many requests did that just cost?
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
What the batch saves and what it costs
500 individual POST /contacts calls cost 500 round trips (sequential: minutes on a high-latency link), 500 authentication checks, 500 rate-limit tokens, and 500 chances for a transient failure to leave the client unsure which items landed. A POST /contacts:batch with 500 items costs one round trip and one auth check — and creates a request body large enough to need Large Requests and Documented Limits limits, a response that must describe 500 outcomes, and a failure mode the single endpoint never had: *some* of it worked.
The first contract decision is the size cap. Unbounded batches are a memory and timeout problem — a 50,000-item batch holds a connection for minutes and a transaction for as long — so the cap (100? 1,000?) is a promise about server capacity, returned as 413 or a 422 BATCH_TOO_LARGE with the limit in the error, and the client paginates its own input. The second decision is whether the batch is a convenience over the single endpoint (same validation, same authorization per item, same error codes) or a different operation with different semantics. Convenience is what consumers expect; say so, and make the per-item errors identical to what the single endpoint would have returned.
Rate-limit accounting is the decision most often left implicit. Does a 500-item batch consume one request token or 500? If one, batches become the way around rate limits and a single call can do 500 writes of work; if 500, the client must know the batch will be rejected outright when its budget has 300 left — or accepted with 300 processed and 200 marked RATE_LIMITED. Either is a defensible clause of The Rate-Limit Contract; an undocumented one is a surprise.
| Model | On item failure | Response | Server cost | Use when |
|---|---|---|---|---|
| All-or-nothing | Whole batch rejected; nothing persisted | One error naming the failing items | One transaction; size-bounded | Items are interdependent, or a half-applied batch is worse than none (financial postings) |
| Best-effort, independent | Each item succeeds or fails on its own | Per-item results, in order | Independent writes; no long transaction | Items are independent (contacts import); caller can retry failures |
| Validate-all-then-apply | Validation failures reject the batch; runtime failures are per-item | Either one error or per-item results | Two passes | Bad input should never partially apply, but dependency outages are tolerable |
| Async batch job | Reported in job result | 202 + job resource | Worker capacity | Batches too large or slow for a request; see The Async Job Pattern |
The per-item result is the contract
Best-effort batches must return a result per item, in request order, each carrying either the created resource (or its id) or an error in exactly the shape of The Error Model: Structure Over Apology — same code, same field errors, same retryability. The overall status is the awkward part: 200 with per-item failures is defensible when the batch itself succeeded ("we processed your batch; here is what happened"), and 207 Multi-Status exists for the purpose but many clients do not handle it. What is not defensible is 200 with a body that hides failures, or 400 for the whole batch because one item was invalid under a best-effort contract — see Partial Failure: When 3 of 5 Succeed for the general rule.
A summary block (succeeded: 497, failed: 3) saves the client from counting, and a failed_indices or per-item index field lets a client retry only the failures. That retry must be safe: each item should carry its own idempotency key (or a client reference id the server dedups on) so that retrying the three failures — or the whole batch after a lost response — does not create 497 duplicates. The batch-level Idempotency-Key alone is not enough once a client resubmits a *subset*; Idempotency Keys: The Mechanism explains why the key must match the operation being retried.
Ordering is the last clause. Does the server process items in request order? Does item 3 see the effects of item 2 (create a folder, then create a file in it)? For independent best-effort batches the honest answer is "no ordering guarantee, no cross-item visibility"; for batches that need sequencing, the contract should either promise in-order execution or require the client to sequence across batches.
POST /contacts:batch
Idempotency-Key: 3a1f…
{ "items": [
{ "ref": "c1", "email": "a@b.c", "name": "A" },
{ "ref": "c2", "email": "not-an-email", "name": "B" },
{ "ref": "c3", "email": "d@e.f", "name": "D" }
] }200 OK
{
"summary": { "total": 3, "succeeded": 2, "failed": 1 },
"results": [
{ "ref": "c1", "status": "created", "id": "ctc_91a" },
{ "ref": "c2", "status": "failed", "error": { "code": "VALIDATION_FAILED", "retryable": false,
"errors": [ { "field": "email", "code": "INVALID_FORMAT" } ] } },
{ "ref": "c3", "status": "created", "id": "ctc_91b" }
]
}
# Retry only c2 after fixing it; c1/c3 are never re-created because "ref" is the per-item dedup key.Choosing atomicity honestly
All-or-nothing sounds safer and is often the wrong promise. It requires one transaction spanning every item — fine for 50 rows in one database, impossible when each item calls a payment provider or a downstream service (There Is No Transaction Across APIs). It also makes the batch as fragile as its worst item: one invalid email rejects 499 valid contacts, and the client has to find, fix and resubmit. It is the right promise when items are interdependent or when partial application creates an inconsistent state a human must clean up — financial postings, inventory reservations that must balance.
Best-effort is honest about independence and scales to any backend, at the cost of pushing failure handling onto the client: it must read every result, retry failures, and tolerate a world where 497 contacts exist and 3 do not. Validate-all-then-apply is the pragmatic middle for many imports — reject bad input outright, tolerate runtime failures per item — and it is worth stating that the two failure classes get different responses.
Whichever model the contract chooses, the batch endpoint should not be the *only* way to do the operation. Keep the single-item endpoint; make the batch a documented convenience with the same semantics; and when batches grow beyond what a request can hold (tens of thousands of items, minutes of work), stop pretending it is synchronous and move to a job resource (Long-Running Operations: 202 and the Job Resource).
- Cap batch size and return the cap in the error; the client paginates its input.
- State atomicity: all-or-nothing, best-effort, or validate-then-apply — per endpoint, in the docs.
- Per-item results in request order with the single-endpoint error model; a summary block for counts.
- Per-item dedup via client
refor per-item idempotency key so subsets can be retried. - Rate-limit accounting stated: one token per batch or one per item, and what happens at the boundary.
Key points
- A batch trades round trips for a new failure class — partial success — and the contract must say what that means before the first client hits it.
- Cap the size, keep the single-item endpoint, and make per-item semantics identical to it: same validation, same authorization, same error codes.
- All-or-nothing needs one transaction over every item; it is the right promise for interdependent items and impossible across external services.
- Per-item results in request order, with per-item dedup keys, are what make a best-effort batch safely retryable by subset.
- Rate-limit accounting for batches is a contract clause; undocumented, batches become either a loophole or a surprise rejection.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Client → API: needs 500 contacts created; makes 500 sequential calls; hits the rate limit at 300 and has no record of which succeeded.
- 2Team → batch endpoint: ships
POST /contacts/bulkwith no size cap and a whole-batch400on any invalid item. - 3Integration → batch: sends 20,000 items; the request holds a transaction for four minutes and times out at the load balancer; nothing is committed, or half is.
- 4Client → retry: resubmits the whole batch after the timeout; the half that committed is now duplicated because there was no per-item dedup.
- 5Support → team: "we have every contact twice, except the ones with invalid emails, which are missing".
- Duplicated or missing records after retries, with no per-item record of what happened.
- Long transactions from unbounded batches hold locks and connections, degrading every other request on the database.
- Whole-batch rejection for one bad item turns a large import into a find-the-needle exercise for every consumer.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Cap batch size; reject over-cap batches with an error carrying the limit; document that clients paginate input.
- • Choose and state the atomicity model per endpoint; default to best-effort with per-item results unless items are genuinely interdependent.
- • Return per-item results in request order using the single-endpoint error model, plus a summary block.
- • Require a client `ref` or per-item idempotency key and dedup on it, so subset retries and whole-batch retries are both safe.
- • Document rate-limit accounting for batches and move very large or slow batches to an [[async-job-pattern]] job.
- • Batch size distribution and per-item failure rate by error code show whether consumers are hitting the cap or sending bad input systematically.
- • Transaction duration and lock wait time on batch endpoints reveal all-or-nothing batches that have outgrown a request.
- • Duplicate-key rejections on the per-item `ref` are retries working as designed — a healthy signal, not an error to page on.
- • Lowering the size cap is breaking for clients sending large batches; raise freely, lower with a deprecation window and telemetry on batch sizes.
- • Switching from all-or-nothing to best-effort changes semantics clients depend on — a new endpoint or version, never a silent flip.
- • Adding an async batch job alongside the synchronous batch is additive; clients above the cap migrate to it.
- • Per-item results and dedup keys make the response and the server logic larger than a simple array of created ids.
- • Best-effort pushes failure handling to clients; all-or-nothing pushes it to the server's transaction and to every client on the same database.
- • Counting batches as one rate-limit token is generous and exploitable; counting per item is fair and harder to explain.
Misconceptions
ref or per-item key) protects the items that already succeeded.