FIELD NOTE 010 / PARTIAL SUCCESS
How to Handle Partial-Success Batch APIs Without Duplicate Work
A practical pattern for item-level outcome ledgers, selective retries, reconciliation, and idempotency when a batch API completes only part of a request.
By Harrison Ndeke · Published August 31, 2026 · Updated August 31, 2026 · 14 min read
BATCH RECOVERY
DISPATCH → CLASSIFY → RECONCILE → RETRY
In this article
DIRECT ANSWERFor a partial-success batch API, create a durable ledger before the call, classify every item from the response, and retry only items the provider explicitly left unprocessed or identifies as transiently failed. Treat a timeout or missing response as unknown, not failed: reconcile by an idempotency key or a read-back query before sending anything again.
Key takeaways
- A 200 response can still contain item failures. BatchWriteRecord returns
ErrorsandUnprocessedEntries; entries absent from both lists are the successful set. AWS API reference - Retry the subset, not the original batch. A batch can commit some records while leaving others unprocessed. Replaying the whole request creates duplicate-work risk unless each side effect is independently idempotent.
- Keep a per-item outcome ledger. It is the record that lets a worker resume, an operator investigate, and a reconciler distinguish done work from unknown work.
- Unknown is a real state. A lost response, timeout, or worker crash does not prove that the provider did nothing. Check first, then retry only if the check is conclusive.
- Bound retries and surface terminal work. Invalid, forbidden, and exhausted items need a reasoned stop path, not an infinite loop.
What does partial success change?
A transactional batch either commits as one unit or rolls back. A partial-success API is different: every item is processed independently, and a response can name the items that failed or were not processed while the rest have completed. Amazon SageMaker Feature Store's BatchWriteRecord, for example, accepts up to 25 entries and returns item-level Errors and UnprocessedEntries. It does not roll back completed records because another record in the same call failed. AWS's launch explanation
The useful consequence is simple. Build around item identity, not batch identity. A batch ID still helps with tracing, but it cannot safely answer whether a particular message, record, or charge should be sent again.
This matches the boundary in Harrison's public idempotency field note: retries need a stable business key and a durable record of the committed effect. That is a proposed implementation pattern based on the public material, not a claim about a production incident or measured result.
What should the per-item outcome ledger contain?
Write the ledger before dispatching the provider call. Store a payload reference or hash rather than secrets or an unnecessary full customer payload. The row should preserve the same idempotency key across technical attempts.
type Outcome =
| "pending"
| "in_flight"
| "succeeded"
| "retryable"
| "reconcile"
| "terminal";
type ItemOutcomeLedger = {
operationId: string; // one business operation
batchId: string;
itemId: string; // stable business item identity
idempotencyKey: string; // stable across retries
payloadHash: string;
attempt: number;
outcome: Outcome;
providerRequestId?: string;
providerObjectRef?: string;
errorCode?: string;
errorMessage?: string;
nextAttemptAt?: string;
updatedAt: string;
};The ledger is not a substitute for provider idempotency. It coordinates your own workers and gives you somewhere to retain state when a process dies after dispatch. Where the provider accepts an idempotency key, pass the stable key through. Stripe documents that a repeated request with the same key returns the saved first status and body, and that reusing a key with different parameters is rejected. Stripe's idempotent-request reference
How should a worker classify each item?
| Observed result | Ledger outcome | Next action |
|---|---|---|
| Item is absent from explicit failure and unprocessed lists | succeeded | Persist a completion record; do not include it in the retry subset. |
Listed in UnprocessedEntries | retryable | Retry that exact item after bounded backoff. |
| Explicit transient error, such as provider unavailability | retryable | Retry the item only, using the same key and a policy-specific delay. |
| Validation, authentication, authorization, or immutable business-rule error | terminal | Stop automatic retries and route a useful error to the owner or remediation queue. |
| Timeout, connection reset, malformed response, or worker crash after dispatch | reconcile | Look up the effect or provider receipt before retrying. |
A provider's documented codes decide the exact classifier. SageMaker lists validation errors as 400, forbidden access as 403, and internal failure or service unavailability as 500 and 503. AWS API reference Do not turn that example into a universal retry chart. Other APIs may use different semantics.
What retry algorithm avoids duplicate work?
Claim each pending row atomically before sending it. If a worker cannot claim it, another worker owns the attempt and the current worker should move on. Send only claimed rows, record the request identifier if one is available, then update each row from the provider response.
async function processBatch(ids: string[]) {
const items = await ledger.claim(ids); // pending or due retryable rows
if (!items.length) return;
await ledger.markInFlight(items);
try {
const response = await provider.send(
items.map(item => ({ ...item.payload, idempotencyKey: item.idempotencyKey }))
);
const failures = indexByItemId(response.errors ?? []);
const unprocessed = indexByItemId(response.unprocessedEntries ?? []);
for (const item of items) {
if (unprocessed[item.itemId]) {
await ledger.retry(item, backoff(item.attempt));
} else if (failures[item.itemId]) {
const error = failures[item.itemId];
await ledger.record(item, isTransient(error)
? { outcome: "retryable", nextAttemptAt: backoff(item.attempt) }
: { outcome: "terminal", errorCode: error.code });
} else {
await ledger.record(item, { outcome: "succeeded" });
}
}
} catch (error) {
await ledger.markForReconciliation(items, String(error));
}
}Use exponential backoff with jitter and an attempt cap. Stripe's reference specifically recommends exponential backoff for 429 rate limiting. Stripe API reference The cap, delay, and retryable-code list are service and business-policy choices. For an irreversible action, the safer policy can be to reconcile once and escalate rather than retry automatically.
How do you reconcile an unknown outcome?
Begin with the business item and its stable key. Query a provider endpoint that can return the created object, search by external reference, or query your own system of record for the committed effect. If it exists and matches the payload hash or expected material fields, mark the row succeeded. If the lookup proves it does not exist, move it back to retryable. If the lookup is unavailable, ambiguous, or returns a mismatched effect, keep reconcile and send it to a review queue.
Do not infer absence from a paginated listing that can have concurrent-write gaps. AWS documents that its ListRecords results can contain duplicates or gaps during concurrent writes. AWS's ListRecords guidance Prefer an exact lookup by item identity where the provider supports one.
Harrison's public workflow guidance also pairs retries with explicit error paths and observable state. See Retries and fallbacks in n8n workflows and workflow observability. Those notes show design boundaries, not production measurements.
Which tests should prove the boundary?
| Fixture | Expected ledger result | Assertion |
|---|---|---|
| All items succeed | Every row is succeeded | One request; no retry scheduling. |
| Mixed success and unprocessed items | Completed rows stay complete; only unprocessed rows are due | Second request contains the subset only. |
| One invalid item | Invalid row is terminal | No automatic retry for that row. |
| Transient item error | One row becomes retryable | Delay grows and retains the same idempotency key. |
| Timeout after provider accepted the request | Rows become reconcile | Read-back marks existing effects complete; no blind replay. |
| Two workers receive the same event | One claim succeeds | At most one provider submission per item attempt. |
| Reused key with changed payload | Row is blocked or terminal | Never silently attach new business meaning to an old key. |
| Retry limit exhausted | Row is terminal or reviewable | Alert includes item ID, attempt count, and safe diagnostic reference. |
What are the limits of this pattern?
Idempotency depends on scope and retention. Stripe says keys can be removed after at least 24 hours, after which reuse creates a new request; your own ledger therefore needs a retention rule that matches the business effect and provider behaviour. Stripe API reference A provider may not expose a read endpoint, may only offer eventual consistency, or may not accept a client key. In those cases, reconciliation may require an internal outbox, a provider receipt, or human review.
This Field Note describes a design approach from public documentation and Harrison's public workflow writing. It does not report a production incident, client deployment, duplicate rate, throughput figure, recovery time, or measured outcome. Test the real API's contracts, retry semantics, data-retention window, and compliance requirements before relying on the pattern.
Executive summary
Partial-success batches require an item-level state machine. Create a durable row for each intended effect, claim it before dispatch, classify the response item by item, and retry only the explicit retry subset. A missing response is not a failure signal. Reconcile it with a stable business identifier or provider receipt, then retry only if the effect is known not to exist. This keeps a technical retry from becoming duplicate business work.
Primary sources
- Amazon SageMaker: BatchWriteRecord API Reference, for request limits, response fields, and API-level errors.
- AWS Machine Learning Blog: Batch write and discover records in SageMaker Feature Store, for partial-success and ListRecords behaviour.
- Stripe: Idempotent requests, for stable-key retry semantics and retention limitations.
- Harrison Ndeke: Prevent duplicate workflow executions with idempotency, for the related workflow boundary.