Approach: Use the Idempotency-Key as the client-visible dedupe token. Persist an idempotency record with status (processing/success/failure), response metadata (HTTP status, headers, body), timestamps, and an expires_at (24h). Use strong DB constraints + short distributed lock (Redis) to handle concurrent requests safely and avoid duplicate side-effects. On retry, return stored response when completed; if processing, wait or return 202 with retry-after.Pseudocode (Python-like):python
# Assumptions:
# - Postgres table idempotency_keys(idempotency_key pk, owner_id, status, response_status, response_headers json, response_body bytea, created_at, expires_at)
# - Redis for short locks with SETNX and TTL
# - process_payment() performs the external side-effect (charge) and may raise
def handle_post(request):
key = request.headers.get("Idempotency-Key")
if not key:
return 400, {"error": "Missing Idempotency-Key"}
lock_key = f"idemp-lock:{key}"
got_lock = redis.set(lock_key, uuid(), nx=True, ex=10) # 10s safety TTL
row = db.select_for_update("SELECT * FROM idempotency_keys WHERE idempotency_key=%s", key)
if row is None:
# create a processing record in a transaction to ensure single writer
with db.transaction():
try:
db.execute("INSERT INTO idempotency_keys (idempotency_key, status, created_at, expires_at) VALUES (%s,'processing', now(), now()+interval '24 hours')", key)
except UniqueViolation:
# another concurrent inserter won race; read row
row = db.select("SELECT * FROM idempotency_keys WHERE idempotency_key=%s", key)
if row and row.status == "success":
# replay stored response exactly
return row.response_status, deserialize_headers(row.response_headers), row.response_body
if row and row.status == "processing":
if got_lock:
# we are the processor; continue
pass
else:
# another process is handling it -> avoid duplicate side-effect
# Option A: wait/poll with short backoff up to a threshold, then return 202
return 202, {"Retry-After": "3"}, {"message":"Processing"}
# At this point we hold lock (or we are the designated processor)
try:
result = process_payment(request.body) # side-effectful
# persist response atomically
with db.transaction():
db.execute("UPDATE idempotency_keys SET status='success', response_status=%s, response_headers=%s, response_body=%s WHERE idempotency_key=%s",
result.http_status, serialize_headers(result.headers), result.body, key)
return result.http_status, result.headers, result.body
except Exception as e:
# record failure to allow clients to retry and avoid infinite processing
with db.transaction():
db.execute("UPDATE idempotency_keys SET status='failure', response_status=%s, response_headers=%s, response_body=%s WHERE idempotency_key=%s",
500, json.dumps({"error": str(e)}), b"", key)
raise
finally:
if got_lock:
redis.delete(lock_key)
Storage and locking choices:- Primary store: Postgres table with unique constraint on idempotency_key for strong correctness and durability; keeps full response metadata for exact replay.- Locking: Short Redis distributed lock (SETNX + TTL) to coordinate concurrency without long DB transactions, preventing thundering herd. Also use SELECT FOR UPDATE or transactional INSERT to avoid races.- Expiration: expires_at column (now()+24h) and a background job (cron/worker) to purge/compact old rows to save space.- Replay safety: store response headers, status, and body; for large bodies store in object storage and keep pointer in DB.- Observability: emit metrics (idemp.hit, idemp.miss, idemp.concurrent_conflict) and alerts for high contention.Edge cases:- Long-running processing: return 202 with Retry-After or implement client wait/poll.- Partial failures after side-effect: ensure external side-effect is also idempotent or implement compensating actions.- Clock skew: use DB time for expires_at.