Web development
Idempotency and retries: how not to create two orders
Idempotency is designed before retries: a client key, a unique constraint in the database and growing backoff. What to do when the same key arrives with a different body.
Key points
- POST is not idempotent: without a client key every retry creates a duplicate.
- The key needs a unique constraint in the database, not only in memory, and must be kept for at least 24 hours.
- Retry 408, 429, 500, 502, 503 and 504; a 400 or a 422 does not improve with a second attempt.
- Three GET requests to /api/stats returned status 200 and the same 102-byte body on 1 November 2026: repetition is checkable.
Idempotency prevents the second order: the client sends a key with the request, the server stores the first response and returns it again instead of running twice. Retries are the consequence, not the cause. Without a key, every new attempt creates a duplicate.
The proof lies in repetition. On 1 November 2026 we called /api/stats three times in a row: status 200 on every call and the same 102-byte body. A repeated GET changes no state, a POST without a key offers no such promise.
Idempotent does not mean safe
RFC 9110, HTTP Semantics, June 2022, separates two properties that get mixed up. A safe method changes no state: GET and HEAD. An idempotent method may change it, but repeating it produces the same result: PUT and DELETE. POST is not idempotent. If your creation endpoint is a POST, idempotency has to be added by hand, as a piece of protocol the client respects.
The idempotency key in practice
Retries: which errors and how long to wait
Retry a temporary error, not a refusal: 408, 429, 500, 502, 503, 504. A 400, a 401 or a 422 do not improve with a second attempt, and repeating them burns quota. The wait grows exponentially with random jitter, starts at 200-500 ms and stops after three to five attempts. If the response carries Retry-After, that value beats any client calculation.
Two requests, one order
The command below sends two identical POSTs with the same key and compares the identifier that comes back. If the two values differ, the server ran twice and the defect sits in how the key is stored, not in the client.
# due POST identici con la stessa chiave: la seconda deve restituire la prima risposta
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o /tmp/order-$i.json -w "%{http_code}\n" -X POST https://api.esempio.it/v1/orders \
-H "Idempotency-Key: $KEY" -H "Content-Type: application/json" \
-d '{"sku":"ABC-1","qty":1}'
done
# lo stesso ordine, non due
diff <(jq -S .id /tmp/order-1.json) <(jq -S .id /tmp/order-2.json) && echo "stesso ordine"
Same key, different body
If the key arrives with a different body, do not return the first response: reply 409 and leave the key untouched. That rule stops a client bug from overwriting an order that already exists. Compare a fingerprint of the canonical body, not the raw string: two JSON payloads with keys in a different order are equivalent, and a textual comparison treats them as enemies.
The limitation to state
Idempotency is not something you buy from a catalogue: blocks cover the interface, not the database constraint. Two limits remain. The key has to live in a store shared between instances, otherwise two servers answer the same retry differently. And an authorised payment is not undone by repeating the request: you need a refund request with its own key. The standard error model is described in designing an API.
Three catalogue records
The form category holds 32 blocks with fields and validation: that is where double submits start. The table category holds 13, useful for showing an order's state and the key that created it. Table components complete the operator view. The licence is declared by the publisher and needs checking on each block; previews are free, the full code sits in the Pro plan.
On the interface side a double submit is also prevented by disabling the button after the first click: server action validation and uploads in a route handler show the same controls on a form; the two routes are compared in server actions and APIs.
Frequently asked questions
What is the difference between an idempotent request and a retry?
Idempotency is a property of the request: repeating it produces the same state. A retry is the client repeating after an error. A retry is safe only when the request is idempotent or carries a key the server recognises.
How long should an idempotency key be kept?
At least 24 hours, longer than the window in which the client retries. Past that threshold a late retry recreates the resource. If the client queue waits longer, align the window to the queue, not the other way round.
What should I return when the same key arrives with a different body?
409, with a readable error saying which key carries a different body. Do not return the first response and do not overwrite the record: both choices hide a client bug.
Which errors can I retry safely?
408, 429, 500, 502, 503 and 504 are temporary. A 400, a 401, a 403 or a 422 signal a problem a second attempt cannot fix, and repeating them burns quota and logs.
Do I need a distributed lock to handle retries?
Usually not. A unique constraint on the key plus a transaction is enough: the second insert fails and the server reads the stored response. A distributed lock adds coordination and one more failure point.