Returning 409 vs 200 for Replayed Requests

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
Status-code decision tree for an idempotent endpoint Starting from an incoming request with a key, the first branch is whether the key exists. If absent, the handler executes and its status is returned. If present, the fingerprint is compared: a mismatch yields 422. On a match, the key state decides: pending yields 409 in use with retry-after, completed with a 2xx or 4xx stored status replays that status verbatim, and a released key from a prior 5xx executes fresh. request + key atomic register key existed? no execute handler 201 · Idempotent-Replay: false yes fingerprint matches? no 422 payload_mismatch retrying will not help yes state? pending → 409 in_use Retry-After: 1 completed → replay original status, verbatim Idempotent-Replay: true

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.

Storing 4xx versus releasing 5xx Two side-by-side panels. The left panel shows a 422 validation error being stored against the key, so a retry replays the same 422 without re-running validation, bounding a broken client's load. The right panel shows a 503 releasing the key, so the retry executes the handler fresh and can succeed once the downstream recovers. 4xx — STORE the response handler returns 422 invalid_currency key → completed, status 422 retry #2 … #50 replay the stored 422 validation never runs again a broken client costs one execution, not fifty 5xx — RELEASE the key handler returns 503, downstream down key deleted — back to absent retry executes the handler fresh succeeds once the dependency recovers a transient failure stays retryable
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
What changing the status code does to code you do not own A client branch tests whether the status equals two hundred and one. Under verbatim replay both the first call and the retry take the success branch and record the charge. Under a rewritten two hundred on replay the retry falls into the else branch and raises an alert, even though the operation succeeded. verbatim replay (201 → 201) if (res.status === 201) record() first call: recorded retry: recorded — same branch rewritten (201 → 200 on replay) if (res.status === 201) record() first call: recorded retry: else branch → false alert

SRE / observability checklist

  1. idempotent_replay_total — Counter labelled by status and route. The healthy baseline is 0.5%–2% of requests; a jump above 10% means a client is looping.
  2. idempotency_conflict_total — Counter of 409 idempotency_key_in_use. Sustained above 1% of requests means Retry-After is shorter than handler latency.
  3. idempotency_release_total — Counter labelled by the status that caused the release. Must contain only 5xx and 429; anything else is a misconfigured finaliser.
  4. idempotency_expired_replay_total — Counter of 409 idempotency_key_expired. Any non-zero value on a payments route deserves investigation: a client retried past the window.
  5. idempotent_replay span 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.