Retries and idempotency keys
When a client may retry a failed request, and how an idempotency key lets a POST be retried without charging the customer twice.
After this lesson you can
- Decide whether a failed request is safe to retry from its method and the kind of failure
- Explain how an idempotency key turns a retried POST into a replay
- Name the three error cases an idempotency-key store must handle
Networks fail in the worst place: after the request reached the server and before the response reached the client. The client then has no way to know whether the payment, the order or the email happened. Every serious client library retries in some situations, so an API has to be designed for the same request arriving twice.
When a retry is safe
It depends on two things: what the method promises and what kind of failure happened.
- The request never left (connection refused, DNS failure): nothing happened on the server, so any request can be sent again.
- The response was lost (timeout or reset after sending): the request may or may not have been applied. Only an idempotent request — GET, HEAD, OPTIONS, PUT, DELETE — can be repeated without risk.
- The server said "later" (
503 Service Unavailableor429 Too Many Requests, often withRetry-After): wait as told, then retry, again only if repeating cannot double the effect. - The server said "no" (400, 401, 403, 404, 422): the same request will fail the same way. Retrying is noise.
Retries should back off — wait longer after each attempt, with some random jitter — so a struggling server is not hit by every client at the same instant.
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]); function mayRetry(method, failure, hasIdempotencyKey) { if (failure === "connection-refused") return true; // never reached the server if (typeof failure === "number" && failure >= 400 && failure < 500 && failure !== 408 && failure !== 429) return false; // the request itself is wrong // Response lost, 408, 429, 5xx: the request may have been applied. return IDEMPOTENT.has(method) || hasIdempotencyKey;} export function run() { const cases = [ ["POST", "connection-refused", false], ["POST", "timeout", false], ["POST", "timeout", true], ["PUT", "timeout", false], ["DELETE", 503, false], ["GET", 404, false], ["POST", 422, true], ]; return cases.map(([m, f, k]) => `${m} ${f}${k ? " +key" : ""}: ${mayRetry(m, f, k) ? "retry" : "stop"}`);}Making POST retryable
POST is not idempotent, but "create this payment" is exactly the request a client most needs to retry. The common answer is an idempotency key: the client generates a unique value (typically a UUID) for each logical operation and sends it with every attempt of that operation.
POST /payments HTTP/1.1
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json
{"amount": 2500, "currency": "EUR"}
The server records the key with a fingerprint of the request and the response it produced. When the same key arrives again with the same request, the server replays the stored response instead of performing the operation a second time.
function makeServer() { const payments = []; const keys = new Map(); // key -> stored response return { post(body, key) { if (key && keys.has(key)) return { ...keys.get(key), replayed: true }; const payment = { id: `pay_${payments.length + 1}`, ...body }; payments.push(payment); const response = { status: 201, id: payment.id }; if (key) keys.set(key, response); return response; }, count: () => payments.length, };} export function run() { const body = { amount: 2500, currency: "EUR" }; // The first response is "lost"; the client retries. const noKey = makeServer(); noKey.post(body); const retryWithoutKey = noKey.post(body); const withKey = makeServer(); withKey.post(body, "k-1"); const retryWithKey = withKey.post(body, "k-1"); return { withoutKey: { retry: retryWithoutKey, payments: noKey.count() }, withKey: { retry: retryWithKey, payments: withKey.count() }, };}The IETF HTTP APIs working group has drafted a standard Idempotency-Key
request header; at the time of writing it is still an Internet-Draft, not
an RFC, but payment APIs have used the pattern for years. The draft
recommends a response for three error cases, and they are worth copying:
a missing key on an endpoint that requires one (400), the same key
reused with a different request body (422), and a retry arriving while
the first attempt is still being processed (409).
The parts that are easy to get wrong
- Claim the key atomically. Two attempts can arrive at once. Insert the key under a unique constraint before doing the work, so exactly one attempt proceeds and the other sees "in progress".
- Store the outcome, not just the key. A replay must return the original response — the same payment id — or the client cannot tell a replay from a fresh success.
- Scope and expire keys. A key belongs to one client or account, and is kept for a bounded window (hours or days), after which it may be reused.
Common mistakes
- Retrying a POST on timeout with no idempotency key: the client creates two payments, as the first run of the example shows.
- Generating a new key for each attempt instead of each operation, which makes every retry look new.
- Checking "does the key exist?" and then inserting it in two separate steps, so two concurrent attempts both pass the check.
- Retrying 400 or 422 responses: the request is wrong, and sending it again cannot fix it.
What interviewers ask
- A payment request timed out. What should the client do? — retry with the same idempotency key; the server either replays the stored result or performs it once. Never retry a non-idempotent request blind.
- How would you implement idempotency keys on the server? — store key, request fingerprint and response; claim the key atomically before the work; replay on a match; reject a mismatched body; expire after a window.
Try it yourself
2 visible tests · 3 hidden testsImplement handle(requests). Each request is { key, body }, where
key is a string or null and body is { amount, currency }.
Process the requests in order and return an array with one response
per request:
keyisnullor""→{ status: 400 }.keynot seen before → create a payment whose id is"pay_N", where N counts created payments from 1, and respond{ status: 201, id }.keyseen before with an equal body → replay:{ status: 201, id, replayed: true }with the id stored for that key. Two bodies are equal when theiramountandcurrencyare equal.keyseen before with a different body →{ status: 422 }.
Only a 201 that is not a replay creates a payment. A 400 or 422 changes nothing that later requests can see.
handle([{"key":"a","body":{"amount":100,"currency":"EUR"}},{"key":"a","body":{"amount":100,"currency":"EUR"}}])handle([{"key":"a","body":{"amount":100,"currency":"EUR"}},{"key":"b","body":{"amount":100,"currency":"EUR"}},{"key":null,"body":{"amount":100,"currency":"EUR"}}])
Sign up to run the tests, check the hidden ones and save your progress. Sign up