Reach for this skill when the same operation may execute more than once and a second execution must NOT produce a second effect:
-
Make it idempotent BY DESIGN before reaching for a key — that's cheaper and self-healing. A surprising amount of "double effect" disappears if the operation is naturally repeatable:
| Technique | How | Why it's idempotent |
|---|
| PUT / upsert to a client-chosen id | PUT /orders/{client_uuid} → INSERT ... ON CONFLICT (id) DO NOTHING/UPDATE | second call hits the same row, no new row |
| Conditional write (optimistic concurrency) | If-Match: <etag> / WHERE version = N → bump version | stale retry's precondition fails → no double-apply |
| Natural / deterministic key | derive id from stable inputs (hash(order_id+sku), not uuid()) | same inputs → same id → conflict, not insert |
| Set, don't increment | balance = 100 not balance += 10; status = 'paid' | reapplying the same set is a no-op |
| DELETE / "ensure absent" | delete-by-id, "cancel if active" | already-gone is success, not error |
Increments, "append a row", and server-generated ids on POST are the non-idempotent shapes that force you to step 2.
-
For non-idempotent POSTs, use a client-supplied Idempotency-Key. The client (not the server, not per-retry) generates ONE key for a logical operation and sends it on the original request AND every retry — header Idempotency-Key: <opaque-uuid>. The key must be stable across retries and unique per operation: generate it once before the first send, store it with the in-flight request, reuse it on retry. This is the Stripe model and the reference semantics to copy.
-
Persist the key with a dedup table — fingerprint, status, and the stored response. One row per key:
CREATE TABLE idempotency_keys (
id_key text NOT NULL,
scope text NOT NULL,
request_hash text NOT NULL,
status text NOT NULL,
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
PRIMARY KEY (scope, id_key)
);
Scope the key per (user/tenant, endpoint) so one client's key can't collide with or replay another's. Retention 24h–7d (Stripe = 24h); a TTL/cron purges expired rows so storage is bounded.
-
Server flow — claim, execute, store, replay — atomically. On each request:
- Compute
request_hash from the canonical (sorted/normalized) body + method + route.
- Atomically claim the key:
INSERT (scope, id_key, request_hash, status='in_progress') ON CONFLICT (scope, id_key) DO NOTHING. The insert is the lock.
- If the insert won (0 conflicts): run the real operation, then
UPDATE ... SET status='completed', response_code, response_body and return the response.
- If it conflicted, read the existing row:
status='completed' and request_hash matches → return the stored response_code/response_body verbatim (the replay path — same result, no re-execution).
status='in_progress' → a concurrent duplicate is still running → return 409 Conflict (or 425-style "in progress"); the client should retry-after, not re-execute.
request_hash differs (same key, different body) → return 422 Unprocessable Entity — the key was reused for a different operation; never run it.
-
Hold a lock for the in-flight window so concurrent duplicates don't both execute. The INSERT ... ON CONFLICT DO NOTHING claim handles most of it, but if you read-then-write, take a row lock: SELECT ... FOR UPDATE on the key row, or a Postgres advisory lock pg_advisory_xact_lock(hashtext(scope||id_key)) around the whole claim+execute. Without this, two parallel retries can both see "no row" and both run. Wrap claim + business write + result-store in one transaction (or make the business write itself idempotent via step 1) so a crash between execute and store doesn't lose the recorded response.
-
Consumer-side dedup for at-least-once queues and webhooks — "exactly-once delivery" is a myth; you get effectively-once. Brokers (SQS, Kafka, RabbitMQ) and webhook senders redeliver on ack timeout, so the consumer must be idempotent. Two patterns:
- Processed-event-id store: a
processed_events(event_id PRIMARY KEY, processed_at) table. Before handling, INSERT ... ON CONFLICT DO NOTHING; if 0 rows inserted, it's a duplicate → ack and skip. Dedup on the provider's stable event id (not your own per-receipt uuid). Pairs with ingest-webhook-secure / message-queue-jobs.
- Dedup window: a bounded TTL set (Redis
SET key NX EX <window>) when full history is too large — only safe if redelivery is bounded within the window.
Best of all: make the handler naturally idempotent (step 1: upsert by event-derived key, set-don't-increment) so even a missed dedup is harmless.
-
Atomic write + publish → outbox pattern (no dual-write). Writing the DB row and publishing the event as two separate calls can crash between them (row saved, event lost — or vice versa). Instead, in one transaction write the business row AND an outbox row; a separate relay polls/CDC-tails the outbox and publishes (at-least-once → consumers dedup per step 6). The transaction guarantees the event is recorded iff the state changed.
-
DB mechanics cheat-sheet.
- Postgres/SQLite:
INSERT ... ON CONFLICT (cols) DO NOTHING (claim/dedup) or DO UPDATE SET ... (upsert). MySQL: INSERT ... ON DUPLICATE KEY UPDATE. The UNIQUE constraint/index is what makes it safe — ON CONFLICT without a matching unique index silently doesn't dedup.
- Serialize the in-flight window with
SELECT ... FOR UPDATE (row) or pg_advisory_xact_lock (cross-row/logical) — released at transaction end.
- Check the result of the upsert (rows affected /
RETURNING xmax = 0) to know whether you inserted or hit an existing row.
Done = duplicate and concurrent requests produce exactly one effect with an identical replayed response, same-key/different-body returns 422, the in-flight window is locked, consumers dedup at-least-once delivery on stable event ids, write+publish is atomic via the outbox, and key storage is TTL-bounded — all proven by the parallel/redelivery tests in checks 1–7.