Chapter 2 — Retries and Idempotency
A retry is a second request: it adds load to something already failing, and it may do the work twice
Prerequisites: Chapter 1 — Timeouts and Deadlines. Deep chapters: SD QA Q29 — Retry systems, Q26 — Idempotency for write APIs, Q43 — Delivery guarantees, JS 7, Part 6 — Retry with backoff, jitter and a budget, Debugging 13 (Case 4: retry storm, Case 6: charged twice), SD 2 Ch 23 — Payments.
How to read this chapter
Every retry makes two bets: that the same request might succeed if sent again, and that sending it again is safe. The deep chapters cover the first bet well: backoff, jitter, budgets, retry storms. Part 1 summarises them in the form you need in the room. The rest of the chapter is about the second bet, which the reader covers only in pieces: which failures are safe to retry, why a timeout leaves you not knowing whether the work happened, and how idempotency keys and deduplication make "maybe twice" harmless.
The measured runs used Node 22.23 and Postgres 18 on a laptop. The interview version of this topic is usually one line: "The charge request timed out. What does your client do?"
| Term | Meaning |
|---|---|
| Retryable error | A failure that might not happen on the next attempt (a dropped connection, a 503), as opposed to one that will (a 400) |
| Safe method | An HTTP method the client expects to have no side effects: GET, HEAD, OPTIONS, TRACE |
| Idempotent | Doing it twice has the same effect as doing it once. The responses may differ |
| Unknown outcome | You sent the request and got no answer. The work may or may not have happened |
| Idempotency key | A unique ID the client creates once per intent and sends with every attempt, so the server can recognise repeats |
| At-least-once delivery | Every message arrives, possibly more than once. What almost every queue and webhook system provides |
| Dedupe | Recording what you have already processed so a repeat is skipped |
| Retry amplification | Retries at several layers multiplying one request into many |
| Retry budget | A cap on retries as a share of normal traffic, e.g. 10% |
| Metastable failure | A system that stays down after the trigger is gone, held there by its own retry load |
Part 1: The load half, in five sentences
The deep chapters (Q29, JS 7, Debugging 13 Case 4) cover this. What you must be able to say:
- Retries are load, and they arrive when the dependency can least take it. One retry on an 80% failure rate turns 1,000 req/s into 1,800.
- Retries multiply across layers. Three layers that each make 3 attempts send 3 × 3 × 3 = 27 calls to the bottom for one user action. Retry at one layer, usually the one closest to the failing call.
- Use exponential backoff with full jitter:
delay = random() × min(cap, base × 2^attempt). Backoff alone spaces the waves out; jitter breaks them up, because clients that failed together otherwise retry together. - Cap retries with a budget, e.g. retries may be at most 10% of recent requests. Under a full outage, traffic grows by 10%, not by 3×. When the budget is spent, fail fast.
- Honour
Retry-Afterand the deadline. Retry only if the remaining budget (Chapter 1) can fit a useful attempt.
In the sim, 40 clients call a provider that is down from 2 s to 6 s and can take 8 requests per 100 ms tick once it is back. Run it with Fixed 1 s, then Exponential, then Exp + jitter, and compare the "Peak after recovery" stat with the capacity.
Who retries without asking you
The multiplication in point 2 usually happens because layers retry by default, and nobody counted them.
| Layer | Retries by default? |
|---|---|
Node fetch (undici) | No. Measured: a server that reset the connection received exactly one GET, one POST and one PUT |
| nginx as a reverse proxy | proxy_next_upstream error timeout by default: on a connection error or timeout it tries the next upstream server. Since 1.9.13 it does not do this for POST, PATCH or LOCK unless you add non_idempotent |
| Envoy / a service mesh | Envoy retries only when a route has a retry policy. Some meshes ship one by default; check yours |
| AWS SDK | Yes: 3 attempts in total in its standard mode |
| OpenAI and Anthropic Node SDKs | Yes: maxRetries defaults to 2 (3 attempts) |
| Your job queue | Whatever you set as attempts (BullMQ), or redelivery until the message goes to a dead-letter queue (SQS) |
| Your own code | Whatever you wrote, often a withRetry someone added during an incident |
The reader's world makes this concrete. An LLM generation job with 3 queue attempts, an SDK with 3 attempts per call, and failover across 3 providers can make up to 27 provider calls for one job during a regional outage. Pick the retry layer on purpose. For a queued job that is usually the queue: set the SDK's maxRetries: 0 inside jobs, keep failover, and let the queue's backoff handle the rest.
Part 2: Safe and unsafe retries
Before any retry, three questions:
- Will it be different next time? A
400will not. A dropped connection might. - Did the first attempt happen? Sometimes you know it didn't. Often you can't know.
- Is doing it twice harmless? Only if the operation is idempotent, or you made it so.
What HTTP methods promise
RFC 9110 defines which methods are safe (no side effects intended) and which are idempotent (repeating them has the same effect):
| Method | Safe | Idempotent | Note |
|---|---|---|---|
GET, HEAD, OPTIONS, TRACE | Yes | Yes | |
PUT | No | Yes | Replace the whole resource with this value. Twice leaves the same state |
DELETE | No | Yes | The first returns 204, the second 404. Different responses, same effect |
POST | No | No | "Create" or "do": twice means two |
PATCH | No | No | {"op":"increment"} twice adds two |
The method is a promise your handler has to keep. Proxies, SDKs, prefetchers and link scanners rely on it. A GET /confirm?token=... that confirms a subscription will be "clicked" by email security scanners that fetch every link to check it. A PUT /wallet/credit that adds to the balance breaks every layer that retries PUT because it is supposed to be idempotent.
Which failures are retryable: sort them by where they failed
Q29 lists retryable status codes. The more useful split is by how far the request got, because that decides question 2:
| What you saw | Did the server do the work? | Retry? |
|---|---|---|
DNS failure, ECONNREFUSED, connect timeout, TLS failure (UND_ERR_CONNECT_TIMEOUT) | No. The request never reached the application | Yes, any method, even POST |
| Your own timeout fired while waiting for a pooled connection | No, but it surfaces as the same TimeoutError as a slow server (Chapter 1, Part 2) | Yes, if you can tell. Log pool waits separately so you can |
429, or 503 with Retry-After from the service itself | Usually no: rate limiters and load shedders reject before doing work | Yes, after the delay, if the service documents that it rejects before acting |
Response timeout, ECONNRESET / UND_ERR_SOCKET after the request was sent | Unknown | Only if idempotent, or with an idempotency key |
502 / 504 from a proxy or load balancer | Unknown: the backend may have finished after the proxy gave up | Only if idempotent, or with a key |
500 | Unknown, and possibly a bug that fails every time | Idempotent only, few attempts |
400, 401, 403, 404, 422 | No: rejected | No |
409 "already in progress" from an idempotency layer | It is being done right now | Yes, later, with the same key |
AbortError from your caller | Irrelevant: they cancelled | No |
The rows marked unknown are the hard part, and they are the ones that cause real double charges.
Part 3: A timeout means an unknown outcome
A client timeout ends the client's wait. It sends nothing to the server, and the server's handler keeps going (Chapter 1 measured the same thing across three hops). So after a timeout on a write, there are three possible states, and the client cannot tell them apart:
- The request never arrived.
- It arrived and is still running.
- It finished, and the response was lost or late.
Measured: three "failures", three charges
A POST /charges handler that takes 1,500 ms to commit. The client gives each attempt 1,000 ms and retries twice with jittered backoff.
| Client saw | Server received | Charges created | |
|---|---|---|---|
| No key | TimeoutError × 3, then "gave up" | 3 requests | 3 (ch_1, ch_2, ch_3) |
Same Idempotency-Key on every attempt | TimeoutError, then 409 (first attempt still running), then 200 {"id":"ch_1"} replayed | 3 requests | 1 |
Read the first row again: the client reported failure to the user, and the user was charged three times. Retrying made it worse, and not retrying would have shown "payment failed" for a payment that went through.
The same thing happens in the browser with a double click. In the sim, set the fix to None (3 stories charged), then Disable (2: the disabled button stops the second click but not the first click's own retry), then Idempotency key (1).
Three ways to resolve an unknown outcome
| Strategy | How | Good for |
|---|---|---|
| Retry with the same idempotency key | The server recognises the key and returns the first result | Your own APIs, and providers that accept keys (most payment APIs do) |
| Look it up, then decide | Query by your own reference (GET /charges?order=8812) before retrying | Providers without keys, or when a retry would be expensive |
| Make it someone else's job | Mark the order pending, answer "processing", and let a worker or a reconciliation job settle it, possibly from the provider's webhook | Payments, anything slow, anything where the user can wait for an email |
Cancellation helps but cannot close the gap. If the handler checks the abort signal before committing, a request that timed out early won't commit. But the commit can succeed a millisecond before the client's timeout fires, and then the response is lost on the way back. Only idempotency makes the retry safe in every case.
Part 4: Idempotency keys, beyond the basics
Q26 covers the core: the client creates the key per intent, the server stores the key with a status and the response, a unique constraint settles races, a payload hash catches key reuse with a different body (422), and keys expire after a window (24–72 h). Here is what it leaves out.
When the side effect is in your own database: one transaction
If the work is a write to your own Postgres (debit a wallet, create a job), put the key claim and the work in one transaction:
BEGIN;
-- 1. Claim the key. Exactly one concurrent request gets a row back.
INSERT INTO idempotency_keys (tenant_id, key, request_hash, status)
VALUES ($1, $2, $3, 'processing')
ON CONFLICT (tenant_id, key) DO NOTHING
RETURNING key;
-- No row returned: someone else holds or held this key.
-- SELECT status, response FROM idempotency_keys WHERE tenant_id = $1 AND key = $2;
-- 'done' → replay the stored response. Hash differs → 422.
-- 2. The work, in the same transaction.
UPDATE wallets SET balance = balance - $4 WHERE id = $5 AND balance >= $4;
INSERT INTO charges (wallet_id, amount) VALUES ($5, $4);
-- 3. Store the response.
UPDATE idempotency_keys SET status = 'done', response = $6 WHERE tenant_id = $1 AND key = $2;
COMMIT;
Two behaviours make this work, both checked on Postgres 18:
- A concurrent duplicate waits, then sees the result. Session 1 claimed key
k1, debited the wallet and held the transaction open for 3 s. Session 2'sINSERT … ON CONFLICT DO NOTHINGfor the same key blocked for 2.5 s until session 1 committed, then returned no row and readdonewith the stored response. One charge, balance 100 → 20. - A crash rolls back the key with the work. Session 1 claimed
k2, debited the wallet, then failed (division by zero) and rolled back. Session 2, waiting on the same key, then got the row and did the work. Again one debit. No row is ever left stuck inprocessing.
The wait in the first case holds a pooled connection. If transactions are long, add SET LOCAL lock_timeout = '200ms' before the claim: the duplicate then fails after 200 ms with canceling statement due to lock timeout (SQLSTATE 55P03, measured), and you return 409 so the client retries later.
When the side effect is someone else's API: forward a key, and record progress
You can't put Stripe or an LLM provider inside your transaction. The pattern is to split the work into steps, each committed before the next:
- Derive the provider's key from your own record (
order-8812-charge), not from a fresh UUID per attempt. Then any process that resumes the order, the API, a worker, a reconciliation job, sends the same key and the provider deduplicates. - A row stuck in
pendingis not an error; it is a recovery point. A job that finds orders pending for more than a few minutes retries the provider call with the same key, or looks the charge up. This is how a crash-resumable generation engine works, and it is the same idea. - Check the provider's key window. Payment APIs keep keys for a limited time (Stripe, for example, at least 24 hours). A recovery job that retries after that window can charge again.
Natural idempotency is often better than a key table
A generic key table is not the only tool. Many operations can be made idempotent by their own data:
| Operation | Natural idempotency |
|---|---|
| Create an order for a checkout session | UNIQUE (checkout_session_id) on orders. A duplicate fails with 23505; return the existing order |
| Refund a failed job's credits | UNIQUE (job_id, kind) on the ledger. Four recovery paths can reach the same refund; only one row can exist |
Move a job from queued to running | UPDATE jobs SET status = 'running' WHERE id = $1 AND status = 'queued'. The second update matches no rows |
| Set a user's plan | Write the value (plan = 'pro'), never a delta |
| Count a view | Not naturally idempotent. Either accept rare double counts, or dedupe on an event ID |
Keep a unique constraint where a duplicate would be a real-world error, even when you also have keys. It is the last net when every other layer has a bug.
Part 5: At-least-once delivery and dedupe
Queues, webhooks and event streams almost all deliver at least once (Q43). A consumer that processes a message and crashes before acknowledging it gets the message again. So every consumer is, in effect, a retried request with an unknown outcome, and the same answer applies: make processing idempotent.
Order of operations in the consumer
| Order | What a crash in the middle does | Guarantee |
|---|---|---|
| Ack, then process | The message is gone and the work never happened | At-most-once: loses work |
| Process, then ack | The message comes back and the work runs again | At-least-once: duplicates work |
| Process + record the message ID in one transaction, then ack | The message comes back, the ID is found, it is skipped | Effectively once |
BEGIN;
INSERT INTO processed_messages (consumer, message_id) VALUES ('ledger', $1)
ON CONFLICT DO NOTHING RETURNING message_id;
-- no row: already processed. COMMIT, ack, done.
UPDATE wallets SET balance = balance + $2 WHERE id = $3;
COMMIT;
-- then ack the message
Details interviewers push on:
- Dedupe on the producer's event ID, not the broker's delivery ID. If the producer republishes (say, from an outbox after a crash, Q49), a broker assigns a new delivery ID but the event ID is the same.
- The dedupe window must outlast redelivery. If messages can be redelivered for 4 days, keeping IDs for 24 hours lets a late redelivery through.
- Side effects outside your database can't join the transaction. For "send an email", sending then recording risks a duplicate email on a crash in between; recording then sending risks a lost one. Choose per effect. A duplicate receipt is better than a lost one; a duplicate SMS to a customer's phone might not be. Pass a key to the provider where it accepts one.
- Jobs longer than the visibility timeout get redelivered while still running. That's a duplicate with no crash at all (Debugging 13, Case 8). Extend the lease while working, or keep jobs shorter than the timeout.
When the Interviewer Breaks It
Answer each one out loud before opening it.
Break 1: "The charge request timed out. What does your client do?"
Answer
30-second answer: "A timeout means I don't know if the charge happened. The request may never have arrived, may still be running, or may have finished with the response lost. So the client retries with the same idempotency key it generated when the user clicked Pay, with jittered backoff and inside the request's deadline. The server treats a repeat key as the same intent: if the first attempt is still running it returns 409 and the client tries again shortly, and if it finished it replays the stored response. If time runs out, the UI says 'processing' rather than 'failed', and the order settles asynchronously from the provider's webhook or a reconciliation job."
The follow-up they push on: "What if the connection was refused instead?" Then the request never reached the server, so the outcome is known: nothing happened, and a retry is safe even without a key. That's why separate connect and response timeouts are worth having (Chapter 1).
A weak answer sounds like: "Show an error and let the user try again." In a test, three client timeouts produced three charges while the client reported failure. The user clicking again is a fourth.
Break 2: "The payment provider is down for five minutes. What do your retries do?"
Answer
30-second answer: "Without limits, every checkout retries several times at every layer, so the provider gets a multiple of normal traffic exactly when it's down, and when it comes back the synchronised retries can knock it over again. I'd retry at one layer only, with exponential backoff and full jitter, under a retry budget of about 10%, and put a circuit breaker in front so that after a burst of failures we stop calling and fail fast. For the user, checkout shouldn't depend on the provider answering in real time: accept the order as pending and let a worker charge it once the provider is back, at a controlled rate, with the same idempotency keys."
The follow-up they push on: "The provider is back. How fast do you drain the backlog of pending orders?" At a fixed rate below the provider's limit, ramping up, not all at once. Recovery traffic is a retry storm too (Chapter 4).
A weak answer sounds like: "Exponential backoff." On its own, without jitter, a budget or one retry layer, it just moves the spikes.
Break 3: "Your queue delivered the same 'credit purchased' message twice. The user got double credits."
Answer
30-second answer: "Queues deliver at least once, so duplicates are normal, not a bug in the queue. The consumer has to make processing idempotent. I'd record the producer's event ID in a processed_messages table in the same transaction as the ledger insert, with a unique constraint, and acknowledge only after commit. A redelivery then finds the ID and does nothing. Better still, the ledger row itself is keyed by the purchase ID, so a duplicate credit can't exist at all."
The follow-up they push on: "Why not acknowledge first, so it's never redelivered?" Then a crash after the ack loses the credit. Ack-first is at-most-once. For money you want at-least-once plus dedupe.
A weak answer sounds like: "Use exactly-once delivery." Exactly-once inside one broker exists, but it ends at the boundary with your database. The consumer still has to be idempotent.
Break 4: "Two retries with the same idempotency key arrive in the same millisecond."
Answer
30-second answer: "A read-then-insert check would let both through, so the claim is an insert against a unique constraint: INSERT … ON CONFLICT DO NOTHING. Exactly one gets the row. In Postgres, if the first is still in its transaction, the second insert waits until it commits, then gets no row and reads the stored response. If the first rolls back, the second gets the claim and does the work. I'd put the work in the same transaction when it's local, and set a short lock_timeout on the claim so a duplicate returns 409 quickly instead of holding a connection."
The follow-up they push on: "And when the work is an external API call?" It can't be in the transaction. I commit the claim and a pending record first, call the provider with a key derived from that record, and commit the result after. A crash leaves a pending row that a recovery job resumes with the same provider key.
A weak answer sounds like: "Check if the key exists first." That's check-then-act across two statements, the race the key was meant to prevent.
Break 5: "Walk me through one user request during an outage. How many calls hit the LLM provider?"
Answer
30-second answer: "Count the layers. The mobile client retries twice, so 3 attempts. Each hits our API, which enqueues a job with 3 attempts, and each job attempt uses an SDK with 3 attempts by default. That's 3 × 3 × 3 = 27 calls for one user action, and failover across providers multiplies it again. I'd make the queue the only retry layer for generation: the client doesn't retry a request that already got a job ID, it polls; the SDK's retries are off inside jobs; the queue backs off with jitter and under a budget, and a breaker per provider skips a dead one rather than trying it every time."
The follow-up they push on: "Why the queue and not the SDK?" The queue's retries survive a crash, wait longer, and are visible in one place. SDK retries are invisible, happen inside a held worker slot, and stack with everything else.
A weak answer sounds like: "We retry three times." At which layer? The interviewer's point is that nobody counted the layers.
The Causal Chains
- If a write times out and the client retries without a key
- If the client disables the button but its HTTP helper retries on timeout
- If three layers each make three attempts
- If the key claim is a
SELECTfollowed by anINSERT - If the key claim and a local side effect share one transaction
- If a consumer acknowledges before it commits
- If a connect timeout fires
Checklist: add this to any design
- One retry layer per call path, chosen on purpose. Count the defaults: SDKs, proxies, mesh, queue, client.
- Exponential backoff with full jitter, a retry budget, and a circuit breaker on every retried dependency.
- Retry only what can succeed: connection failures,
429/503afterRetry-After, timeouts on idempotent or keyed requests. Never4xxvalidation errors or a caller's abort. - Retry only inside the deadline, and only if a useful attempt still fits.
- An idempotency key on every write a client or worker may retry, scoped per tenant, with a payload hash and an expiry longer than the retry window.
- Key claim with
INSERT … ON CONFLICTagainst a unique constraint, in the same transaction as local side effects; a shortlock_timeouton the claim. - For external side effects, a pending record first and a provider key derived from it, plus a recovery job that resumes pending records with the same key.
- Unique constraints on business identities (
checkout_session_id,(job_id, kind)) as the last net. - Consumers that dedupe on the producer's event ID in the same transaction as their effect, and acknowledge after commit.
- "Processing", not "failed", when the outcome is unknown, and a reconciliation path that settles it.
What's next
Timeouts bound how long you wait and retries recover from transient failures. Neither stops a slow or failing dependency from spreading to the parts of the system that don't need it. Chapter 3 — Isolation and Blast Radius covers circuit breakers, bulkheads (separate pools per dependency and per priority) and keeping one tenant, region or feature from taking down the rest.