← Back to Field Notes

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

Wordless dark basalt systems illustration with amber items moving through a central gate toward an ice-cyan completed grid, vermilion retry items in a narrow lane, an amber item in a clear reconciliation capsule, and a barrier blocking a full batch replay.

BATCH RECOVERY

DISPATCH → CLASSIFY → RECONCILE → RETRY

In this article
  1. Direct answer
  2. Key takeaways
  3. Partial success
  4. Outcome ledger
  5. Classification
  6. Retry algorithm
  7. Unknown outcomes
  8. Tests
  9. Limitations
  10. Executive summary

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

  1. A 200 response can still contain item failures. BatchWriteRecord returns Errors and UnprocessedEntries; entries absent from both lists are the successful set. AWS API reference
  2. 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.
  3. 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.
  4. 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.
  5. 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 resultLedger outcomeNext action
Item is absent from explicit failure and unprocessed listssucceededPersist a completion record; do not include it in the retry subset.
Listed in UnprocessedEntriesretryableRetry that exact item after bounded backoff.
Explicit transient error, such as provider unavailabilityretryableRetry the item only, using the same key and a policy-specific delay.
Validation, authentication, authorization, or immutable business-rule errorterminalStop automatic retries and route a useful error to the owner or remediation queue.
Timeout, connection reset, malformed response, or worker crash after dispatchreconcileLook 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?

FixtureExpected ledger resultAssertion
All items succeedEvery row is succeededOne request; no retry scheduling.
Mixed success and unprocessed itemsCompleted rows stay complete; only unprocessed rows are dueSecond request contains the subset only.
One invalid itemInvalid row is terminalNo automatic retry for that row.
Transient item errorOne row becomes retryableDelay grows and retains the same idempotency key.
Timeout after provider accepted the requestRows become reconcileRead-back marks existing effects complete; no blind replay.
Two workers receive the same eventOne claim succeedsAt most one provider submission per item attempt.
Reused key with changed payloadRow is blocked or terminalNever silently attach new business meaning to an old key.
Retry limit exhaustedRow is terminal or reviewableAlert 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.

Related services and reading

About the author

Harrison Ndeke is an AI automation developer in Nairobi who builds documented workflows, AI agents, chatbots, RAG systems, and API integrations. His public writing discusses validation, duplicate controls, explicit error paths, and observable workflow states. This article applies those published boundaries to partial-success batch APIs; it does not claim formal certification, incident-response work, or measured client outcomes.

Primary sources