Idempotency Keys (API-Level Deep Dive)
A client retries a POST that may have succeeded. Without idempotency, you charge their card twice. With idempotency keys, the second request returns the first response and does nothing else.
Pattern (Stripe-style)
Client generates a unique key per logical operation:
POST /v1/charges
Idempotency-Key: a3f7b9d1-1c8c-4f8c-9e6e-1234abcd5678
Content-Type: application/json
{ "amount": 1000, "currency": "usd", "customer": "cus_42" }
Server stores (key → response). Same key replayed → return stored response unchanged.
HTTP/1.1 201 Created
Idempotency-Replayed: true
{ "id": "ch_5", "amount": 1000, ... }
Data model
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
request_hash TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress', 'completed', 'failed'
response_code INT,
response_body JSONB,
locked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at);
Scope the key to user_id + method + path. Two different users with the same key by coincidence shouldn’t collide; the same user replaying the same key should hit the same record.
Server flow
@app.post("/v1/charges")
async def charge(req: ChargeIn, idempotency_key: str = Header(...), user = Depends(current_user)):
request_hash = hashlib.sha256(json.dumps(req.dict(), sort_keys=True).encode()).hexdigest()
async with db.transaction():
record = await db.fetchrow("""
SELECT * FROM idempotency_keys
WHERE key = $1 AND user_id = $2
FOR UPDATE
""", idempotency_key, user.id)
if record:
if record["request_hash"] != request_hash:
raise HTTPException(422, "Idempotency-Key reused with different payload")
if record["status"] == "completed":
return JSONResponse(record["response_body"], status_code=record["response_code"])
if record["status"] == "in_progress":
# Someone else is currently processing this key
raise HTTPException(409, "Conflict: request in progress")
# First time: create row in-progress
await db.execute("""
INSERT INTO idempotency_keys (key, user_id, request_hash, method, path, status)
VALUES ($1, $2, $3, $4, $5, 'in_progress')
""", idempotency_key, user.id, request_hash, "POST", "/v1/charges")
# Outside the lock: do the work
try:
charge_obj = await stripe.charge(req)
body = charge_obj.dict()
await db.execute("""
UPDATE idempotency_keys
SET status = 'completed', response_code = 201, response_body = $1, completed_at = now()
WHERE key = $2
""", body, idempotency_key)
return JSONResponse(body, status_code=201)
except Exception as e:
await db.execute("""
UPDATE idempotency_keys
SET status = 'failed', completed_at = now()
WHERE key = $1
""", idempotency_key)
raise
The tricky middle states
Request mid-flight: client retries before first response arrived
A network blip — client gives up on the original request and retries with the same key. Server sees status='in_progress'. Options:
- Reject with 409 (simplest). Client backs off and retries; eventually the original completes and the second retry will return the stored response.
- Block briefly waiting for in-progress to complete (timeout, then 409). Better UX but more complex.
Stripe’s actual behavior: returns 409 Conflict on concurrent retries with the same key.
The original request crashed mid-processing
Row is in_progress. The original Python process died. The retry sees in_progress and rejects. What now?
Solutions:
- Time-bound
in_progress. Iflocked_at < now() - 30 seconds, treat as failed and let the retry proceed. - Background sweeper that flips stale
in_progresstofailed.
Without this, a single crash leaves the idempotency key stuck until manually cleared.
Same key, different payload
if record["request_hash"] != request_hash:
raise HTTPException(422, "Idempotency-Key reused with different payload")
Don’t silently return the stored response — that masks a client bug. 422 forces the client to either correct the payload or use a new key.
Storage and retention
Idempotency keys live for 24h typically (Stripe’s window). After that, deletion is safe — a request that old is genuinely a new request.
DELETE FROM idempotency_keys WHERE expires_at < now();
Run as a periodic job. Without retention, the table grows forever.
Scope: what counts as “the same request”
The request hash should cover:
- Request body.
- URL path parameters.
- Significant query parameters.
- Potentially the user’s authorization context (user_id).
Should NOT cover:
- Timestamp headers (
Date:). - Tracing IDs (
X-Trace-Id:). - Volatile metadata.
Hash a normalized, sorted JSON representation.
Idempotency keys + outbox + at-least-once = exactly-once
Idempotency at the API layer + outbox at the side-effect layer + at-least-once delivery downstream = the system processes each logical request exactly once, end-to-end.
Client: POST /charges with Idempotency-Key: K
Server: (check K) → already done? Return stored.
→ not done? Process, store result keyed by K.
Inside the same transaction, write outbox event "ChargeSucceeded".
Relay: reads outbox, publishes to Kafka.
Consumer: dedupes by event ID.
Robust to: client retry, server crash, broker outage, consumer crash, network blips. Each layer handles its own redelivery; the keys/IDs tie everything together.
Distributed concerns
If your API runs on N pods, all see the same idempotency_keys table — the DB row lock (FOR UPDATE) serializes concurrent retries of the same key. The lock is per-row; doesn’t affect requests with different keys.
For ultra-high-volume APIs where DB lock contention matters, use a Redis-based fast path: check Redis first, fall through to DB on miss.
Client responsibilities
- Generate UUID per logical operation, persist client-side. Same logical op → same key. New logical op → new key. Bad clients reuse keys for unrelated operations and get 422s.
- Retry on network errors, NOT on 4xx. A 422 means the request is bad; retrying doesn’t help.
- Reasonable retry backoff. Exponential with jitter.
Stripe’s SDK does this automatically; many DIY clients don’t.
Common bugs
- Caching by key without checking request hash. Client sends
{amount: 100}, then with same key sends{amount: 100000}. Server returns the 100 response, refuses the 100000 charge — but client thinks the 100000 succeeded. - No retention. Table grows forever; eventually slows down lookups.
- In-progress lock never expires. Stuck keys after crashes.
- Storing the key without scoping to user. Cross-user collision (unlikely with UUIDs but real with short keys).
- Returning 200 instead of the stored status code. Stored response should preserve original status.
- Idempotency on GET requests. GETs are idempotent by HTTP design. The idempotency-key pattern is for POST/PUT/DELETE.
Interview angle
- “How do you handle a client retrying a POST charge after a network timeout?” — idempotency keys. Client generates a UUID per logical operation, sends as
Idempotency-Keyheader. Server stores (key → response); same key replayed returns the stored response, doesn’t re-execute. Stripe-style. - “What if the client retries with the same key but a different payload?” — return 422 (or 409). Indicates a client bug — same logical operation should mean same payload. Don’t silently return the original response; that hides the bug.
- “How long do you keep idempotency keys?” — 24 hours is the typical window (Stripe’s default). Old enough that legitimate retries have already happened; short enough to bound storage. Periodic cleanup job.
- “What’s the race condition with concurrent retries of the same key?” — both requests arrive almost simultaneously. Without a lock, both proceed and you charge twice. Solution: row-level lock (SELECT … FOR UPDATE) on the idempotency_keys row before processing. The second request waits or 409s.
- “What if the original request’s server crashes mid-processing?” — row stays
in_progressuntil either a sweeper marks it failed orlocked_atages out. Without that, retries seein_progressand 409 forever. Time-bound the in-progress state. - “Why scope keys by user_id?” — defense in depth against accidental cross-user key collisions and abuse. UUIDs make collisions astronomically unlikely, but scoping is cheap insurance.
- “How do idempotency keys compose with outbox?” — API layer: idempotency keys ensure the work runs once. Outbox layer: the resulting event publishes reliably (at-least-once). Consumer layer: dedupes by event ID. Together: exactly-once processing end-to-end.