0) Problem Restatement
A client may send the same request twice: the user double-clicks "Pay", the app retries after a timeout (the first request actually succeeded), or the network duplicates a packet. For operations like charging a card or creating an order, doing it twice is a serious bug. Design a reliable way (asked at OpenAI) to make the operation take effect exactly once and give the client a consistent response every time it retries.
1) The Core Idea: Idempotency Keys
- The client generates a unique key (a UUID) per logical operation (not per HTTP attempt) and sends it:
Idempotency-Key: 5f3c.... All retries of the same operation reuse the same key. - The server remembers keys it has processed and their results. If the key was seen before, it returns the saved response instead of doing the work again.
"Idempotent" means doing it once or many times has the same effect.
2) Server Design
CREATE TABLE idempotency_keys (
scope TEXT, -- e.g. user or merchant id, so keys can't collide across customers
key TEXT,
request_hash TEXT, -- fingerprint of the request body
status TEXT, -- in_progress, completed
response_code INT, response_body JSONB,
locked_until TIMESTAMP, created_at TIMESTAMP,
PRIMARY KEY (scope, key)
);
2.1 Flow
Architecture Diagram
flowchart LR
R["Request + Idempotency-Key"] --> I{"Insert key as in_progress"}
I -->|"inserted - new"| W["Do the work in a transaction"]
W --> S["Save response, status completed"]
S --> OUT["Return response"]
I -->|"exists, completed"| REPLAY["Return saved response"]
I -->|"exists, in_progress"| WAIT["409 / retry later"]
I -->|"exists, different body"| ERR["422 key reused with different request"]- Try to insert
(scope, key, request_hash, status='in_progress'). The primary key makes this atomic: only one request can win. - Won → do the operation, ideally in the same database transaction as saving the result (
status='completed',response). Then either both happen or neither. - Lost, and the existing row is completed → return the stored response. The client sees the same result as the first time.
- Lost, and still in progress (a concurrent duplicate) → return
409 Conflict/ "try again", or wait briefly and then return the result. - Same key, different request body (hash mismatch) → reject with an error. It's a client bug.
2.2 Crashes in the middle
If the server crashes after inserting in_progress but before finishing, the row has a locked_until. After it expires, a retry may take over. For external side effects (a charge at a payment provider), the work itself must also be safe to repeat: pass the same idempotency key downstream to the provider, so a second attempt returns the original charge instead of creating a new one.
3) Other Details
- Expiry: keep keys for 24 hours to 7 days (clients shouldn't retry after that), then clean them up with a TTL.
- Scope: keys are unique per account, so two customers can't collide.
- Where to store: the same database as the business data is best (one transaction). Redis works for low-risk cases (
SET key NX+ a stored response), but loses the atomicity with DB writes. - Natural idempotency: some operations are idempotent by design.
PUT /users/42 {name}sets a value, and "create order with client-provided order_id" uses a unique constraint. Prefer these when possible. - Messaging: for events and queues, the same idea applies with the event ID. Consumers keep processed IDs, or make writes upserts.
4) Wrap-Up
Have clients send one idempotency key per logical operation, reused across retries. On the server, atomically insert the key as in-progress, do the work and store the response in the same transaction, replay the stored response for repeats, reject concurrent or mismatched duplicates, and expire old keys. Pass the key to downstream providers so external side effects are also deduplicated. That's what "exactly once" means in practice: at-least-once delivery plus idempotent processing.