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.
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:
# 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:
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.
Related
- Retryable vs Non-Retryable Errors — up-link: the classification this page applies to
POST - Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Idempotency Key Storage, TTL and Eviction — the server-side machinery that makes these retries exact
- Configuring Exponential Backoff for 5xx Errors — the delay policy between attempts
- Circuit Breaker Thresholds for HTTP Clients — when to stop retrying altogether