22 min read

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?"

TermMeaning
Retryable errorA failure that might not happen on the next attempt (a dropped connection, a 503), as opposed to one that will (a 400)
Safe methodAn HTTP method the client expects to have no side effects: GET, HEAD, OPTIONS, TRACE
IdempotentDoing it twice has the same effect as doing it once. The responses may differ
Unknown outcomeYou sent the request and got no answer. The work may or may not have happened
Idempotency keyA unique ID the client creates once per intent and sends with every attempt, so the server can recognise repeats
At-least-once deliveryEvery message arrives, possibly more than once. What almost every queue and webhook system provides
DedupeRecording what you have already processed so a repeat is skipped
Retry amplificationRetries at several layers multiplying one request into many
Retry budgetA cap on retries as a share of normal traffic, e.g. 10%
Metastable failureA 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Honour Retry-After and 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.

Loading simulation…

Who retries without asking you

The multiplication in point 2 usually happens because layers retry by default, and nobody counted them.

LayerRetries 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 proxyproxy_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 meshEnvoy retries only when a route has a retry policy. Some meshes ship one by default; check yours
AWS SDKYes: 3 attempts in total in its standard mode
OpenAI and Anthropic Node SDKsYes: maxRetries defaults to 2 (3 attempts)
Your job queueWhatever you set as attempts (BullMQ), or redelivery until the message goes to a dead-letter queue (SQS)
Your own codeWhatever 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:

  1. Will it be different next time? A 400 will not. A dropped connection might.
  2. Did the first attempt happen? Sometimes you know it didn't. Often you can't know.
  3. 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):

MethodSafeIdempotentNote
GET, HEAD, OPTIONS, TRACEYesYes
PUTNoYesReplace the whole resource with this value. Twice leaves the same state
DELETENoYesThe first returns 204, the second 404. Different responses, same effect
POSTNoNo"Create" or "do": twice means two
PATCHNoNo{"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 sawDid the server do the work?Retry?
DNS failure, ECONNREFUSED, connect timeout, TLS failure (UND_ERR_CONNECT_TIMEOUT)No. The request never reached the applicationYes, any method, even POST
Your own timeout fired while waiting for a pooled connectionNo, 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 itselfUsually no: rate limiters and load shedders reject before doing workYes, after the delay, if the service documents that it rejects before acting
Response timeout, ECONNRESET / UND_ERR_SOCKET after the request was sentUnknownOnly if idempotent, or with an idempotency key
502 / 504 from a proxy or load balancerUnknown: the backend may have finished after the proxy gave upOnly if idempotent, or with a key
500Unknown, and possibly a bug that fails every timeIdempotent only, few attempts
400, 401, 403, 404, 422No: rejectedNo
409 "already in progress" from an idempotency layerIt is being done right nowYes, later, with the same key
AbortError from your callerIrrelevant: they cancelledNo

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:

  1. The request never arrived.
  2. It arrived and is still running.
  3. 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 sawServer receivedCharges created
No keyTimeoutError × 3, then "gave up"3 requests3 (ch_1, ch_2, ch_3)
Same Idempotency-Key on every attemptTimeoutError, then 409 (first attempt still running), then 200 {"id":"ch_1"} replayed3 requests1

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.

#
Client
API handler
Payments table
1
POST /charges, 1 s timeout
attempt 1 starts, needs 1.5 s
1 / 9

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).

Loading simulation…

Three ways to resolve an unknown outcome

StrategyHowGood for
Retry with the same idempotency keyThe server recognises the key and returns the first resultYour own APIs, and providers that accept keys (most payment APIs do)
Look it up, then decideQuery by your own reference (GET /charges?order=8812) before retryingProviders without keys, or when a retry would be expensive
Make it someone else's jobMark the order pending, answer "processing", and let a worker or a reconciliation job settle it, possibly from the provider's webhookPayments, 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:

sql
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's INSERT … ON CONFLICT DO NOTHING for the same key blocked for 2.5 s until session 1 committed, then returned no row and read done with 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 in processing.

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 pending is 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:

OperationNatural idempotency
Create an order for a checkout sessionUNIQUE (checkout_session_id) on orders. A duplicate fails with 23505; return the existing order
Refund a failed job's creditsUNIQUE (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 runningUPDATE jobs SET status = 'running' WHERE id = $1 AND status = 'queued'. The second update matches no rows
Set a user's planWrite the value (plan = 'pro'), never a delta
Count a viewNot 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

OrderWhat a crash in the middle doesGuarantee
Ack, then processThe message is gone and the work never happenedAt-most-once: loses work
Process, then ackThe message comes back and the work runs againAt-least-once: duplicates work
Process + record the message ID in one transaction, then ackThe message comes back, the ID is found, it is skippedEffectively once
sql
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

Self-test: predict each consequence before revealing it.
  • 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 SELECT followed by an INSERT
  • 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

  1. One retry layer per call path, chosen on purpose. Count the defaults: SDKs, proxies, mesh, queue, client.
  2. Exponential backoff with full jitter, a retry budget, and a circuit breaker on every retried dependency.
  3. Retry only what can succeed: connection failures, 429/503 after Retry-After, timeouts on idempotent or keyed requests. Never 4xx validation errors or a caller's abort.
  4. Retry only inside the deadline, and only if a useful attempt still fits.
  5. 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.
  6. Key claim with INSERT … ON CONFLICT against a unique constraint, in the same transaction as local side effects; a short lock_timeout on the claim.
  7. 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.
  8. Unique constraints on business identities (checkout_session_id, (job_id, kind)) as the last net.
  9. Consumers that dedupe on the producer's event ID in the same transaction as their effect, and acknowledge after commit.
  10. "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.