Safe Retries for Non-Idempotent POST

This page belongs to Retryable vs Non-Retryable Errors in the Error Contracts & Resilience Mapping reference. GET, PUT and DELETE are idempotent by definition, so retrying them is a design detail. POST is not, which makes the retry question a correctness question: did the first attempt take effect?

The decision trigger

Failure Was the effect applied?
429 with request_applied: false no — safe to retry
503 before the handler ran no — safe to retry
408 Request Timeout no — the request was never complete
500 from application code unknown — depends where it threw
502/504 from a gateway unknown — the origin may have processed it
Client-side connection timeout unknown — the worst case
TCP reset after the request was sent unknown

Everything in the “unknown” half is the same problem: the client has no evidence either way, and guessing wrong either duplicates a charge or drops an order.

The ambiguous timeout The client sends a POST and gives up after five seconds. The server receives it, commits the effect at six seconds and sends a response the client never reads. A blind retry then creates a second record. client server POST /payments (t = 0s) processing… slow dependency client gives up (t = 5s) COMMITTED (t = 6s) 201 Created — nobody is listening a blind retry now creates a SECOND payment

Spec snippet: making retries safe by contract

# openapi.yaml (OpenAPI 3.1.0)
paths:
  /payments:
    post:
      operationId: createPayment
      parameters:
        - name: Idempotency-Key
          in: header
          required: true                 # the retry mechanism is not optional
          description: >
            A client-generated UUID identifying this logical operation.
            Reuse the SAME value for every retry attempt. Keys are retained
            for 24 hours; a repeat within that window returns the original
            response instead of creating a second payment.
          schema: { type: string, format: uuid }
      responses:
        "201":
          description: Payment created.
          headers:
            Idempotent-Replayed:
              description: "true" when this response was replayed from a stored result.
              schema: { type: boolean }
        "409":
          description: An identical request with this key is currently in flight.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ConflictProblem" }
        "429":
          $ref: "#/components/responses/TooManyRequests"

Marking the header required: true is the decision that converts “clients may retry safely” into “clients cannot fail to”. It also puts the parameter into every generated SDK signature, which is where client authors actually encounter it.

Step-by-step

Step 1 — Classify the failure honestly

// retry-policy.ts — what the client knows, per failure
type Evidence = "not_applied" | "applied" | "unknown";

export function evidenceFor(err: HttpFailure): Evidence {
  if (err.kind === "response") {
    const body = err.problem as { request_applied?: boolean } | undefined;
    if (body?.request_applied === false) return "not_applied";   // server said so
    if (body?.request_applied === true)  return "applied";

    switch (err.status) {
      case 408: return "not_applied";     // request never completed
      case 429: return "not_applied";     // limiter rejects before the handler
      case 503: return "not_applied";     // shed before the handler, normally
      case 500: case 502: case 504: return "unknown";
      default:  return "not_applied";     // 4xx: rejected before any effect
    }
  }
  // Transport-level failures: the request may or may not have arrived.
  return err.kind === "connect_timeout" ? "not_applied" : "unknown";
}

The distinction between a connect timeout and a read timeout is worth encoding: if the connection was never established, nothing was sent and the retry is unambiguously safe. Once bytes are on the wire, only an idempotency key can restore certainty.

Step 2 — Retry with the same key, generated once

// post-with-retries.ts — the key is generated OUTSIDE the loop
import { randomUUID } from "node:crypto";

export async function createPaymentSafely(body: PaymentCreate): Promise<Payment> {
  const idempotencyKey = randomUUID();          // once per logical operation
  const deadline = Date.now() + 10_000;         // total budget, not just attempts
  let attempt = 0;

  while (true) {
    attempt++;
    try {
      const res = await fetch("/payments", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,     // SAME on every attempt
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(Math.max(0, deadline - Date.now())),
      });

      if (res.ok) return res.json();

      const evidence = evidenceFor(await toFailure(res));
      if (evidence === "applied") throw new AlreadyAppliedError(res);
      if (evidence === "unknown" && attempt >= 2) throw new AmbiguousOutcomeError(res);

      const wait = backoffWithJitter(attempt, res.headers.get("Retry-After"));
      if (Date.now() + wait > deadline) throw new RetryBudgetExhausted(res);
      await sleep(wait);
    } catch (e) {
      if (e instanceof AmbiguousOutcomeError || e instanceof AlreadyAppliedError) throw e;
      if (attempt >= 3 || Date.now() > deadline) throw e;
      await sleep(backoffWithJitter(attempt));
    }
  }
}

Generating the key inside the loop is the classic bug: each attempt then looks like a new operation to the server, the deduplication never triggers, and the code passes review because the key is present on every request. The jitter policy itself is covered in Configuring Exponential Backoff for 5xx Errors.

Step 3 — Tell the client which case it hit

// server side — a replayed response is labelled, not silently identical
if (stored) {
  res.setHeader("Idempotent-Replayed", "true");
  return res.status(stored.status).json(stored.body);
}

Labelling the replay lets a caller distinguish “I created this” from “this already existed”, which matters for analytics, for user-facing messaging, and for detecting a client that is retrying more than it should.

Step 4 — Reconcile when certainty is impossible

Some operations cannot carry a key — a legacy third-party endpoint, a fire-and-forget webhook delivery. There, the honest design is reconciliation rather than retry:

Three responses to an ambiguous outcome Blind retry may duplicate the effect. An idempotency key makes the retry exact. Reconciliation queries the server for a client-supplied reference to discover whether the effect exists before deciding. blind retry resend the request no correlation value may duplicate detected only in reconciliation, days later idempotency key same key on every attempt server deduplicates exactly once replay flag distinguishes created from replayed reconciliation query by client reference before retrying eventually exact needs a searchable client-supplied id prefer the middle column; use the right one when the endpoint cannot accept a key
# reconcile.py — ask whether the effect exists before retrying
async def create_or_reconcile(client, payload: dict) -> dict:
    client_reference = payload["client_reference"]        # a value WE generated
    try:
        resp = await client.post("/payments", json=payload, timeout=5.0)
        resp.raise_for_status()
        return resp.json()
    except (httpx.TimeoutException, httpx.HTTPStatusError):
        # Ambiguous. Look for the effect rather than repeating the cause.
        found = await client.get("/payments", params={"client_reference": client_reference})
        matches = found.json()["data"]
        if matches:
            return matches[0]                             # it did happen
        return await create_or_reconcile(client, payload) # it did not; safe to retry

Reconciliation requires that the server expose a query over a value the client controls. Adding a client_reference field to creation endpoints is cheap insurance and makes this pattern available even where an idempotency key is not.

Where a key is impossible, the fallback is to look for the effect rather than repeat the cause:

Recovering from an ambiguous POST outcome If the endpoint accepts an idempotency key, retrying with the same key is exact. If it does not, query for a client-supplied reference to discover whether the effect exists before retrying. does the endpoint accept an idempotency key? yes no retry with the same key the server deduplicates; a replay header distinguishes createdfrom replayed reconcile first query by a client-supplied reference; retry only when the effectis absent add a client_reference field to creation endpoints — it makes reconciliation possible later

Standard compliance

Element Standard Clause Note
POST is not idempotent RFC 9110 §9.2.2 Which is why keys exist
PUT/DELETE are idempotent RFC 9110 §9.2.2 Retry needs no key
Automatic retry guidance RFC 9110 §9.2.2 Do not auto-retry non-idempotent methods
Idempotency-Key header IETF draft Widely deployed, not yet an RFC
Retry-After RFC 9110 §10.2.3 Delay before the next attempt
408 Request Timeout RFC 9110 §15.5.9 Request not received in time

RFC 9110 §9.2.2 is explicit that a user agent should not automatically retry a non-idempotent request. The idempotency key does not change the method’s semantics — it changes the server’s behaviour so that repeating the request is observably harmless, which is what makes the retry defensible.

SDK / codegen downstream effect

  // Without a required key: every caller reinvents retry safety
- const payment = await client.payments.createPayment({ paymentCreate: body });

  // With the header required in the spec, the SDK forces the decision
+ const key = crypto.randomUUID();                       // once per operation
+ const payment = await client.payments.createPayment({
+   idempotencyKey: key,
+   paymentCreate: body,
+ });

A wrapper can then own the retry loop entirely — generate the key, apply the backoff, honour Retry-After — so application code calls one method and gets exactly-once semantics without knowing the mechanism. That wrapper layer is the natural home for the policy, as described in Client SDK Generation Pipelines.

Common mistakes

Mistake Correct approach
Retrying a POST on a timeout with no key Send an idempotency key, or reconcile by client reference
Generating the key inside the retry loop Generate once per logical operation
Treating 500 as safe to retry Classify it as unknown unless the server says otherwise
Unbounded retries Bound both the attempt count and the total time budget
Ignoring Retry-After on 429/503 Honour the delay the server asked for
No replay indication on the response Set a header so callers can distinguish create from replay
No client_reference on creation endpoints Add one; it makes reconciliation possible later

FAQ

Is it ever safe to retry a POST without an idempotency key?

Yes, in the narrow case where the server has told you the request was not applied — a 429 from an edge rate limiter, a 503 shed before routing, a 401 from authentication, or a 408 where the request was never fully received. Those rejections happen before any handler runs, and a well-designed API says so explicitly in the problem body. What is never safe is retrying after a client-side timeout or a 500, because the request may have been processed after you stopped listening. If you cannot obtain the server’s word on it, you need either a key or a reconciliation query.

Which status codes are safe to retry for a POST?

408, 429 and 503 are the reliable ones: all three normally indicate rejection before any side effect, and the latter two usually carry Retry-After telling you when to come back. 500 is ambiguous, because application code can throw either before or after committing. 502 and 504 are ambiguous for the same reason one layer up — the gateway gave up, but the origin may have finished the work. Treat the ambiguous set as retryable only when an idempotency key makes the repeat harmless, and otherwise surface the uncertainty to the caller rather than resolving it by guessing.

How many times should a client retry a POST?

Fewer times than most implementations do, and bounded by time rather than count alone. Two or three attempts with exponential backoff and jitter recovers essentially all genuinely transient failures; beyond that you are mostly adding load to a system that is already struggling, and each additional attempt lengthens the window in which an ambiguous outcome can occur. Set a total budget — the caller’s deadline minus a margin — and abandon the operation when the next backoff would exceed it, reporting the ambiguity honestly instead of retrying until something eventually succeeds.