How-to › Make calls that survive failure

How to report partial failure in a batch endpoint#

Return one outcome per item when a batch of writes half succeeds, name each failed item by the id the client sent, and never answer 200 when nothing was written.

Audience
API producer
Level
intermediate
Topic
Handle and design API errors
Verified

A client posts three ledger lines in one request. Two are written and the third fails validation, and your endpoint has one status code to describe all of it. Answer 200 and the client never learns about the third line. Answer 422 and it sends all three again, and the first two are written twice.

What you get

You will end up with a batch handler that reports one outcome per item, keyed by the id the client sent, under a status code that is wrong for nobody. This is for you if your API accepts an array of writes and a caller has to know which ones landed.

Short answer

Give every item a client-supplied id and answer with one outcome per item, each failure an RFC 9457 problem document. Use 207 when some items failed, 200 when all succeeded, and a 4xx when none did, so a client that reads only the status still learns the truth. Reserve all-or-nothing rejection for a store that can roll the whole batch back.

You will need

Node 22 or later, and a batch endpoint that accepts an array of items. The per-item failures below are RFC 9457 problem documents, and the 207 status comes from RFC 4918 section 13, which is the WebDAV specification.1 Decide the status for each single-item error first; the batch status is derived from those.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
207 Multi-StatusIndependent writes where most succeed and the client can retry the restA body every client has to parse before it knows anything, and a 2xx that still needs readingA half-written batch is a corrupt state in your store
All-or-nothing rejectionA transactional store, and items that only make sense togetherOne bad item rejects every good one, so a large batch rarely lands on the first tryThe items are independent and the batch is big
Elasticsearch _bulk responseClients generated from the schema, which always read the errors flagA 200 on a batch where nothing was written, which a status-only client reads as successClients you do not control, which check the status and stop
Microsoft Graph $batchRequests inside the batch depend on each other and a failure has to cascadeA 200 envelope around per-request statuses, and dependents that fail with 424The items are independent and a cascade refuses work that could have landed

Per-item results make the client do more work, and they are what keeps a large batch practical: a thousand lines with one bad row lands nine hundred and ninety-nine of them. All-or-nothing is simpler to reason about and needs a store that can roll back, which is why DynamoDB documents it as a transaction rather than a batch. The errors-flag shape is the per-item shape with the batch status removed, and the removal is the pitfall this page is about.

Key every item by the id the client sent

Position is the obvious key and the wrong one. The server may skip a duplicate, reject an item before the array is walked, or reorder for the store. After any of those, the client’s index into its own array no longer points at the item that failed. An id the client chose survives all of that, and it doubles as the item’s idempotency key when the client sends it again.

function outcomes(items, store, { write }) {
  const seen = new Set()
  return items.map((item) => {
    if (item === null || typeof item !== 'object') {
      return { id: null, status: 422, problem: { type: `${PROBLEMS}/item-not-object`, title: 'Item is not an object', status: 422 } }
    }
    let problem = validate(item)
    if (!problem && seen.has(item.id)) {
      problem = { type: `${PROBLEMS}/duplicate-id`, title: 'Item id repeated in this batch', status: 422 }
    }
    seen.add(item.id)
    if (problem) return { id: item.id ?? null, status: problem.status, problem }
    if (store.has(item.id)) return { id: item.id, status: 200 }
    if (write) store.set(item.id, { amount: item.amount })
    return { id: item.id, status: 201 }
  })
}

Each outcome carries the id, a status, and, on failure, a problem document with a type URI a client can switch on. The per-item status is the one the same write would have earned as a single request, which is what lets a client reuse its single-request error handling item by item. The outer response is application/json: the application/problem+json media type describes one document, and here there are several inside a list. An item that is not an object gets a failed outcome of its own instead of throwing the batch away. An id already in the store answers 200 without a second write.

Pick the batch status from the item outcomes

The batch status is derived, not chosen. An empty batch is refused first, because it has written nothing, and after that the rule has three branches.

export function multiStatus(items, store) {
  if (items.length === 0) return { status: 422, body: EMPTY }
  const results = outcomes(items, store, { write: true })
  const failed = results.filter((r) => r.problem).length
  const status = failed === 0 ? 200 : failed === results.length ? 422 : 207
  return { status, body: { results } }
}

The third branch is the one that matters. 207 sits in the 2xx class, and Response.ok is true for any status from 200 to 299. A client that checks ok and moves on treats a batch where every item failed as a success. Elasticsearch’s _bulk answers 200 with errors: true and leaves the reading to you. Amazon’s SQS reference tells its callers, in so many words, to check for batch errors even when the call returned a 200.2 A 422 for the all-failed case costs nothing and reaches the clients that never read the body.

Check it worked

Three ledger lines, the second with a negative amount, through all three shapes. Then a batch where every item fails, then a retry of only the failed item.

node demo.mjs
item 2 of 3 fails
multi-status  status 207  written 2 of 3  failed ln_b
errors-flag   status 200  written 2 of 3  failed ln_b
atomic        status 422  written 0 of 3  failed ln_b

every item fails
multi-status  status 422  res.ok false  written 0
errors-flag   status 200  res.ok true  written 0
atomic        status 422  res.ok false  written 0

the client retries only what failed, under multi-status
first call 207, retried ln_b, second call 200
ledger holds 3 lines: ln_a,ln_c,ln_b

the failing item, as the client sees it
{
  "id": "ln_b",
  "status": 422,
  "problem": {
    "type": "https://api.example.com/problems/amount-not-positive",
    "title": "Amount must be greater than zero",
    "status": 422,
    "detail": "amount was -5"
  }
}

The middle block is the whole argument in three lines. Nothing was written under any shape, and only the errors-flag shape says res.ok true. The retry block is what per-item results buy: the client resent ln_b alone, the second call was a plain 200, and the ledger holds three lines with no duplicate of ln_a or ln_c.

The test pins the id rule by running the same batch reversed and asserting the failure is still ln_b.

node --test batch.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 121.023088

When it goes wrong

Every item failed and the client reported success. The endpoint answered 200 with a flag in the body, and the client checked the status and stopped.3 Derive the batch status from the outcomes and answer a 4xx when nothing was written.

The client retried the wrong item. The response reported failures by position, the server had dropped a duplicate before validating, and index 1 in the response was index 2 in the request. Report the client’s own id and never a position.

A retry after a 207 wrote two lines twice. The client resent the whole batch instead of the failed items, and the endpoint had no idempotency rule per item. Treat the client’s item id as the key for that item, so a resend of a line already written answers 200 and leaves the stored line alone, the way an Idempotency-Key does for a single request.

When not to do this

Do not report per-item outcomes from a transactional store. If two of three lines cannot exist without the third, a 207 describes a corrupt ledger accurately, which is no comfort. Validate everything, write nothing on any failure, and answer a 422 that names the first failing item and lists the rest, as the atomic shape here does.

Do not go all-or-nothing on a large batch of independent items. One malformed row in a thousand sends the other nine hundred and ninety-nine back to the client. A client that generates rows from user data will rarely get a thousand clean ones on the first try.

Do not answer 207 for a batch where every item succeeded. The status exists to say that the body needs reading, and a client that learns to ignore it on the happy path ignores it on the other one.

Do not let a batch grow without a cap. A per-item response is as large as the batch, and a client that sends ten thousand items gets ten thousand outcomes back in one body.

Last verified

Verified 2026-09-24 against Node 22.22.2. Both output blocks are what the preceding command printed. The three shapes run as functions over an in-memory store, called directly rather than through an HTTP server.

Footnotes

  1. 207 is a WebDAV status. RFC 4918 defines the Multi-Status body as XML with a multistatus root element, and RFC 9110 does not define 207 at all; the IANA status code registry lists it against RFC 4918. JSON APIs borrowed the number and left the body behind, which is a tidy example of how a status code escapes its specification. 424 Failed Dependency, which Microsoft Graph uses for a request whose parent failed, comes from the same document. ↩︎ Back to text

  2. The sentence is in the SendMessageBatch reference. Because a batch can mix successful and unsuccessful actions, it says, you should check for batch errors even when the call returns an HTTP status code of 200. A vendor documenting the failure mode of its own response shape is rarer than it should be, and the sentence has outlived several generations of client libraries that did not read it. ↩︎ Back to text

  3. Microsoft’s batching guide says a 200 on the batch response does not indicate that the individual requests inside it succeeded. It then describes a batch in which requests 2 and 3 failed with 403 and request 4 with 405, all under that 200. The guide is one of the few that puts the mixed result in its own worked example rather than in a footnote. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.