Requestsbatchbulkpartial failureatomicityrate limitsper-item results

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?

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When one request carries many operations, what does the contract promise about each of them — success, failure, order, atomicity — and what does the caller do with a response that is half of each?
Consumers
Integrations syncing hundreds of records on a schedule; a UI applying one action to a selection ("archive these 40"); a data pipeline importing a CSV; an SDK offering `createMany()` — all wanting fewer round trips, and all needing to know what happened to each item.
The promise
A batch contract states the maximum batch size, whether execution is all-or-nothing or best-effort, returns a per-item result in request order with the same error model as the single-item endpoint, and documents how the batch counts against rate limits and how it can be retried safely.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

Atomicity models a batch contract can promise
ModelOn item failureResponseServer costUse when
All-or-nothingWhole batch rejected; nothing persistedOne error naming the failing itemsOne transaction; size-boundedItems are interdependent, or a half-applied batch is worse than none (financial postings)
Best-effort, independentEach item succeeds or fails on its ownPer-item results, in orderIndependent writes; no long transactionItems are independent (contacts import); caller can retry failures
Validate-all-then-applyValidation failures reject the batch; runtime failures are per-itemEither one error or per-item resultsTwo passesBad input should never partially apply, but dependency outages are tolerable
Async batch jobReported in job result202 + job resourceWorker capacityBatches 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.

A best-effort batch: per-item results, the single-endpoint error model, retryable by index
Request
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" }
] }
Response
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 ref or 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.

  1. 1
    Client → API: needs 500 contacts created; makes 500 sequential calls; hits the rate limit at 300 and has no record of which succeeded.
  2. 2
    Team → batch endpoint: ships POST /contacts/bulk with no size cap and a whole-batch 400 on any invalid item.
  3. 3
    Integration → 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.
  4. 4
    Client → retry: resubmits the whole batch after the timeout; the half that committed is now duplicated because there was no per-item dedup.
  5. 5
    Support → team: "we have every contact twice, except the ones with invalid emails, which are missing".
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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

Claim
“A batch should be atomic; anything else is inconsistent.”
Reality
Atomicity across items requires a single transaction over all of them, which is impossible across external services and harmful for large batches on one database. Independent items with per-item results are consistent — each item is exactly what its result says.
Claim
“One idempotency key on the batch makes retries safe.”
Reality
It makes retrying the identical batch safe. A client that resubmits only the failed items sends a different request with a different key; only per-item dedup (a ref or per-item key) protects the items that already succeeded.
Claim
“Batches should count as one request against rate limits; that is the point.”
Reality
That makes a batch the way around every limit. Whether a batch costs one token or N is a legitimate design choice; the requirement is that the contract says which, and what happens when the budget runs out mid-batch.

Apply it