Notes
The problem
Here's the bug. The app sends 'pay nine ninety-nine'. The server charges the card, but the response is lost on a bad mobile connection. From the app's side, a lost request and a lost response look identical: a timeout. So it retries. And the customer is charged twice.
- request reached the server
- card was charged
- response timed out on the
- way back (bad mobile signal)
- the app cannot tell:
- did it fail, or did only
- the answer get lost?
- so it retries → charged twice
Definition
An operation is idempotent if doing it twice has the same effect as doing it once. 'Set balance to five hundred' is idempotent. 'Subtract one hundred' is not. In HTTP, GET, PUT and DELETE are meant to be idempotent; POST usually is not. Payments, orders and messages are almost always POSTs, so we have to make them idempotent ourselves.
- doing it twice has the same
- effect as doing it once
- idempotent:
- SET balance = 500
- DELETE order 42
- PUT /users/7 {…}
- not idempotent:
- balance = balance − 100
- INSERT INTO orders …
- POST /pay
Idempotency key
The standard fix is an idempotency key. When the user taps Pay, the app generates a unique id once, and sends it with the request and with every retry of that same action. The server stores the key together with the result. Stripe's API works exactly like this.
POST /pay
Idempotency-Key: 7f3a9c1e
{ amount: 999 }
client creates the key ONCE
per user action and reuses it
on every retry
Stripe, PayPal, Adyen APIs
all work this way The retry
On the retry, the server looks up the key, finds 'already paid', and returns the stored response without charging again. Insert the key under a unique constraint before doing the work, so two retries arriving at the same moment can't both proceed. Keep keys for a day or so, then expire them.
row = SELECT … WHERE key = ? if row: return row.response INSERT key (UNIQUE) status=busy charge card UPDATE key SET response = … return response UNIQUE constraint stops two concurrent retries
Consumers
The same idea fixes queue consumers from episode twenty-eight, which receive duplicates. Either record each processed message id in a table with a unique constraint and skip repeats, ideally in the same transaction as the work, or design the write to be naturally idempotent: an upsert of 'order forty-two is shipped' can run twice harmlessly.
queues deliver at-least-once (episode 28) dedupe by message id: INSERT INTO processed(msg_id) → conflict? skip or write with natural keys: UPSERT order_status WHERE order_id = 42
Retries
Now retry well. Put a timeout on every call. Retry only failures that might succeed later: timeouts, 503s, resets, never a 400. Wait longer each time, exponential backoff, and add random jitter so thousands of clients don't retry in the same millisecond. And cap the attempts.
- timeout every call
- retry only safe failures:
- timeouts, 503, connection reset
- NOT 400 bad request
- exponential backoff:
- 100 ms, 200, 400, 800…
- + random jitter
- cap the attempts
Retry storms
Retries can cause outages. A service slows down, every client retries three times, and now the sick service gets four times the traffic. Limit retries with a budget, use circuit breakers that stop calling a failing service for a while, and retry at one layer only, not in the app, the SDK and the load balancer all at once.
- service slows down
- → every client retries 3×
- → 4× the traffic on a
- service that is already sick
- fixes:
- retry budgets (≤ 10% extra)
- circuit breakers: stop calling
- a failing service for a while
- retry at ONE layer only
Decision
Where do you need this? Payments, refunds and transfers. Order creation and bookings. Sending emails, texts and push notifications. Webhook handlers, because senders retry for days. And every queue consumer. The rule: if doing it twice would cost money or trust, make it idempotent.
- payments, refunds, transfers
- order creation, bookings
- sending email / SMS / push
- webhook handlers
- (senders retry for days)
- queue consumers
- rule: if a duplicate costs
- money or trust, make it
- idempotent
Say it like this
The interview answer. The client generates an idempotency key per user action and reuses it on retries. The server stores the key and the result under a unique constraint and returns the stored result on repeats. Consumers dedupe by message id. Retries use timeouts, exponential backoff with jitter, and a cap.
- "What if the client retries?"
- client-generated idempotency
- key per action
- server stores key + result
- with a unique constraint
- return stored result on repeat
- consumers dedupe by msg id
- retries: backoff + jitter + cap