Idempotency, retries & safe writes
The network can lose your response after the server already did the work — so the client can't tell 'it failed' from 'it succeeded but I didn't hear back.' Retry and you might do it twice; don't and you might lose it. This lesson is about making writes safe to retry: which HTTP methods are idempotent by design and why POST isn't, the idempotency-key pattern that makes POST retry-safe (exactly-once effects), and the retry discipline — backoff, jitter, caps — that keeps a recovery from becoming a self-inflicted outage. It's the API-contract face of the same reliability problem the queues lesson raised.
Idempotency, retries & safe writes
Here is the problem that makes writes genuinely hard, and it has nothing to do with your code being wrong. A mobile client sends POST /expenses to add a ₹500 dinner. The server receives it, creates the expense, commits it — and then, as it sends the 201 Created back, the user walks into an elevator and the connection drops. The client waited, timed out, and now faces a question it cannot answer from its own side: did the write happen or not? "Request failed" and "request succeeded but I never got the reply" look identical to the client. If it assumes failure and retries, it creates the expense twice. If it assumes success and doesn't, it might have lost the write. This ambiguity is fundamental — the unreliable network guarantees it — and every robust write path has to have an answer for it.
Safe and idempotent methods
HTTP's method semantics are the first line of defense, and they're not arbitrary — they're a contract about what's safe to repeat:
- Safe methods —
GET,HEAD— have no side effects. They only read. Retrying aGETa hundred times changes nothing, so they're trivially safe to repeat and to cache. - Idempotent methods —
GET,HEAD,PUT,DELETE— produce the same effect whether called once or many times.PUT /expenses/e1with a full body sets that resource to a state, so doing it twice leaves the same state as doing it once.DELETE /expenses/e1removes it; deleting an already-deleted thing is still "it's gone." So a client can safely retry these after a lost response — worst case, it re-applies a state that was already applied. - Not idempotent —
POST— is the dangerous one.POST /expensescreates a new resource each time, so retrying it after a lost response creates a second expense. This is why the elevator problem specifically bitesPOST.
The practical rule that falls out: model writes as idempotent operations where you can (a PUT to a client-chosen id instead of a POST that allocates one), and where you genuinely need POST semantics, make it retry-safe explicitly — which is the idempotency key.
Idempotency keys: making POST safe to retry
The pattern (popularized by Stripe, now standard for any serious write API) is simple and powerful:
- The client generates a unique key for each logical operation — a fresh UUID/ULID per "add this expense" action — and sends it in a header (
Idempotency-Key). - The server, before processing, checks whether it has seen that key before.
- Never seen → process the request, and record the key together with the response it produced.
- Seen before → do not process again; return the stored response from the first time.
- Crucially, a retry reuses the same key (it's the same logical action), while a genuinely new operation gets a new key.
Now the elevator problem dissolves. The client retries with the same key; the server recognizes it already created that expense and replays the original 201 instead of creating a second one. The write happened exactly once, and the client got its answer. This is exactly-once effects — the same phrase from the queues lesson, because it's the same idea one layer up: you don't prevent the duplicate request, you make the duplicate harmless by recognizing and deduplicating it.
Run the double-charge yourself. With the key OFF, send a charge and hit Retry (a lost response) — every retry books another ₹500 and the "actually charged" total runs past what the user meant, with nothing ever erroring. Flip the key ON and retry the same way: the server recognises the key and replays the original 201, so the charge happens exactly once.
current key: key-1The retry carries the same key, so the server recognises it, replays the original 201, and creates nothing — the write happened exactly once even though the request arrived twice. That's the whole pattern.
A few details separate a real implementation from a toy one:
- Scope the key to the user and endpoint (e.g. hash
user_id + key + path), so one user's key can't collide with another's, and the "same key" only means "same operation." - Detect body mismatches. If the same key arrives with a different body, that's a client bug (a key reused for a different operation) — reject it (
409) rather than silently replaying the wrong response. - Store with a TTL. Keys don't need to live forever — a day or so covers realistic retries — so they go in a fast store like Redis with expiry, not permanently in your database.
Retry discipline: don't turn recovery into an outage
The other half is how the client retries. Naive retries are their own disaster:
- Only retry what's safe. Retry idempotent methods and keyed operations freely; do not blindly retry a non-idempotent
POSTwithout an idempotency key — you'll double-submit. - Back off exponentially, with jitter. Don't retry immediately or on a fixed interval. Wait 1s, 2s, 4s, 8s… and add randomness (jitter) so that a thousand clients whose requests all failed at the same instant don't all retry in the same next instant. Backoff spaces out one client; jitter de-synchronizes the herd.
- Cap the retries, then surface a real error. Infinite retries just extend the pain.
- Respect
Retry-After. If the server (or a rate limiter) said 429/503 with aRetry-After, honor it.
Go deeper
- Stripe — idempotent requests — The reference implementation of idempotency keys for a payments API: header contract, key scope, replay behavior, and body-mismatch handling — the pattern Fable's middleware mirrors.
- AWS Builders' Library — "Timeouts, retries, and backoff with jitter" — The definitive treatment of retry storms and why jitter matters, from a team that has watched retries take down services at scale — the failure-mode callout in depth.
- MDN — HTTP request methods (safe & idempotent) — The precise definitions of which methods are safe and which are idempotent, straight from the spec — the contract the whole lesson is built on.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Explain why "the request failed" and "the request succeeded but the response was lost" are indistinguishable to a client, and why that ambiguity makes POST specifically dangerous to retry.
- Define "safe" and "idempotent" for HTTP methods, and explain why PUT and DELETE are idempotent but POST is not — with an example of each.
- Walk through the idempotency-key pattern end to end: what the client sends on a retry vs a new operation, what the server does on a seen vs unseen key, and why this yields exactly-once effects rather than exactly-once delivery.
- Name three details that separate a real idempotency implementation from a toy one (think key scope, body mismatch, and storage lifetime), and what each one prevents.
- Describe a retry storm: how does a brief slowdown become a self-sustaining outage, and how do backoff, jitter, and caps each specifically defuse it?
- Fable requires an Idempotency-Key on every money write and replays on a repeat. Explain why a mobile money app in particular cannot treat this as optional, and how the middleware distinguishes a legitimate retry from a key-reuse bug.