Status-code choices on an idempotent endpoint are a compatibility decision disguised as a semantics debate. Every option is defensible in the abstract; only one of them keeps existing client code working. This guide resolves the choice concretely and is part of API contract design for idempotent endpoints.
Prerequisites. You should have the key lifecycle state machine in place — absent, pending, completed, failed — and request fingerprinting available, because the mismatch branch depends on it.
The decision, in one table
| Situation | Status | Body | Extra headers |
|---|---|---|---|
| First execution succeeds | 201 Created (or whatever the handler returns) |
The real response | Idempotent-Replay: false |
| Same key, same payload, first attempt completed | The original status, verbatim | The stored response | Idempotent-Replay: true |
| Same key, same payload, first attempt still running | 409 Conflict |
{"code":"idempotency_key_in_use"} |
Retry-After: 1 |
| Same key, different payload | 422 Unprocessable Entity |
{"code":"idempotency_key_payload_mismatch"} |
— |
| Same key, past the retention window | 409 Conflict |
{"code":"idempotency_key_expired"} |
— |
First execution returned 4xx |
Replay the original 4xx |
The stored error body | Idempotent-Replay: true |
First execution returned 5xx |
Key released — execute fresh | The new response | Idempotent-Replay: false |
| Dedup store unreachable, fail-closed policy | 503 Service Unavailable |
{"code":"idempotency_store_unavailable"} |
Retry-After: 2 |
Why not 200 on replay
The argument for 200 is that nothing was created this time, so 201 Created is a lie. It is a reasonable reading of RFC 9110 and it is the wrong engineering choice, for three concrete reasons.
It breaks existing clients. Integration code branches on the status. A client that wrote if (res.status === 201) { record(res.body.id) } else { alert() } starts alerting on every successful retry. You cannot fix that from the server side, and you cannot know how many callers wrote it.
It carries no more information. 200 tells the client “this already existed” — but so does Idempotent-Replay: true, without touching the status line. The header is strictly more expressive because it composes with any status.
It makes the contract non-uniform. If a replayed 201 becomes 200, what does a replayed 202 Accepted become? A replayed 204 No Content? Every answer is arbitrary. “Replay verbatim” is the only rule with no special cases.
// Client code that keeps working under the verbatim rule.
const res = await api.post('/v1/charges', payload, { idempotencyKey: key });
if (res.status === 201) {
const replayed = res.headers['idempotent-replay'] === 'true';
ledger.record(res.body.id, { replayed }); // same branch, richer telemetry
}
Why 409 for in-flight, and why not blocking
When a duplicate arrives while the first attempt is still running, three options exist: block until the first finishes, execute anyway, or reject. Executing is wrong by definition. Blocking is tempting and is the trap.
A blocked duplicate holds a connection, a worker thread and a socket for the duration of the original handler. During a retry storm — the exact condition that produces duplicates — that converts a burst of cheap rejections into a burst of expensive held connections, and the server runs out of workers precisely when it most needs them. This is the amplification dynamic described in mitigating thundering herd during retry storms.
// Derive Retry-After from observed handler latency rather than hard-coding 1 second:
// too short and the client hammers, too long and it waits after the work finished.
retryAfter := int(math.Ceil(handlerP95.Seconds()))
if retryAfter < 1 { retryAfter = 1 }
if retryAfter > 10 { retryAfter = 10 }
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"code": "idempotency_key_in_use"})
Storing 4xx, releasing 5xx
The completed state must cover client errors, not just successes.
def finalise(scoped_key: str, status: int, body: bytes) -> None:
if status >= 500:
store.release(scoped_key) # transient — let the retry run
else:
store.complete(scoped_key, status, body, ttl_seconds=86_400)
The asymmetry is the whole point. A 4xx is a verdict about the request itself and will not change on retry, so replaying it costs one execution instead of fifty. A 5xx is a verdict about the moment, so the key must be released or the client is permanently locked out of an operation that would now succeed.
One caveat: a 429 Too Many Requests is a 4xx that is transient. Treat it like a 5xx and release the key, or a rate-limited first attempt poisons the key for its whole TTL.
Verification and testing
Confirm the replay is verbatim.
KEY=01HXYZ3ABCDEFGHJKMNPQRSTVW
for i in 1 2; do
curl -si -XPOST localhost:8080/v1/charges -H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' -d '{"amount":4500,"currency":"gbp"}' \
| sed -n '1p;/[Ii]dempotent-[Rr]eplay/p'
done
# expect: HTTP/1.1 201 Created / Idempotent-Replay: false
# HTTP/1.1 201 Created / Idempotent-Replay: true
Confirm 4xx is stored, not re-executed. Point the handler’s validator at a counter and assert it increments once across two calls.
def test_4xx_is_stored(client, validator_calls):
key = "01H4XXSTORETEST00000000000"
bad = {"amount": 4500, "currency": "zzz"}
first = client.post("/v1/charges", json=bad, headers={"Idempotency-Key": key})
second = client.post("/v1/charges", json=bad, headers={"Idempotency-Key": key})
assert first.status_code == second.status_code == 422
assert second.headers["Idempotent-Replay"] == "true"
assert validator_calls.count == 1 # the handler ran exactly once
Confirm 5xx releases.
def test_5xx_releases(client, downstream):
key = "01H5XXRELEASETEST00000000"
downstream.fail_next()
assert client.post("/v1/charges", json=PAYLOAD,
headers={"Idempotency-Key": key}).status_code == 503
downstream.recover()
assert client.post("/v1/charges", json=PAYLOAD,
headers={"Idempotency-Key": key}).status_code == 201
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
Endpoint switched to 200 on replay, and a partner integration began treating every retry as a failed create |
Revert to verbatim replay immediately, then add the Idempotent-Replay header as the supported signal. Publish the change as a breaking-change notice even though it is a revert. |
idempotent_replay_total by status; a nonzero count at a status the handler never returns proves a rewrite is happening |
429 treated as a storable 4xx, so a rate-limited first attempt poisons the key for 24 hours |
Add 429 to the release set alongside 5xx. Assert it in a test that rate-limits the first call and expects the second to execute. |
idempotency_release_total by status; 429 should appear there, never in idempotency_complete_total |
Retry-After: 1 on a slow handler causes clients to poll once a second for 30 seconds, tripling load |
Derive Retry-After from the handler’s p95 latency, clamped to 1–10 seconds, and cap client attempts. |
idempotency_conflict_total divided by handler_duration_seconds p95 — a ratio above 5 means the header value is too small |
Concurrent duplicates return 409 but the handler still executed twice, because registration was not atomic |
Fix the registration to a single conditional write; the status code is a symptom, not the bug. Add a barrier-released concurrency test per testing & verification. | Effect-row count per distinct key — a ratio above 1.0 is the real alarm, independent of status codes |
SRE / observability checklist
idempotent_replay_total— Counter labelled bystatusandroute. The healthy baseline is 0.5%–2% of requests; a jump above 10% means a client is looping.idempotency_conflict_total— Counter of409 idempotency_key_in_use. Sustained above 1% of requests meansRetry-Afteris shorter than handler latency.idempotency_release_total— Counter labelled by the status that caused the release. Must contain only5xxand429; anything else is a misconfigured finaliser.idempotency_expired_replay_total— Counter of409 idempotency_key_expired. Any non-zero value on a payments route deserves investigation: a client retried past the window.idempotent_replayspan attribute — Boolean on the request span so a trace shows at a glance whether work was done or replayed, as described in propagating idempotency keys through trace context.
Related
- API Contract Design for Idempotent Endpoints — the parent page with the full key lifecycle state machine these codes map onto.
- Designing the Idempotency-Key HTTP Header Contract — the error-code table and response headers referenced throughout this guide.
- Request Fingerprinting to Detect Payload Mismatch — the comparison that produces the
422branch. - Implementing Exponential Backoff Without Overlapping Retries — the client-side schedule that decides how often a
409is observed.