Introduction
Duplicate processing remains a leading source of production incidents. Engineers often treat idempotency keys as a single-header checkbox: add an Idempotency-Key and you're done. In practice, modern guidance from Stripe, Kafka best practices and large-scale systems shows that idempotency keys are necessary but not sufficient. You need a layered approach — gateway locks, soft leases, durable uniqueness in the DB, queue-side inboxes, and deterministic keys for external calls — to make exactly-once effects realistic.
This article gives a practical, implementable 5-layer checklist you can apply this week. Each layer has concrete trade-offs and a short code example.
The 5-layer checklist (summary)
1) Ingest gateway — HTTP idempotency key + request fingerprint
2) Soft lease at ingest — Redis SETNX with an in-progress lease
3) Worker / DB layer — unique constraints + transactional outbox
4) Queue dedup / Inbox — message IDs and an inbox table
5) External calls & saga steps — per-call idempotency keys and persisted step state
Use them together and duplicates must escape every layer to cause a problem — a much safer posture than hoping every client behaves perfectly.
1) Ingest gateway — idempotency keys + fingerprints
Why: The gateway is the first place retries and flaky networks surface. Accept a client-generated idempotency key and scope it to the authenticated principal and route (tenant_id + route + key). Store a fingerprint (hash) of the canonical request body with the key. If a client reuses a key with a different fingerprint, return 409 Conflict or 422.
Practical notes:
- TTL: 24 hours is a good default; extend to 30 days for very long human workflows.
- Partition the key table by tenant/date to avoid unbounded growth.
- Return cached responses for completed keys so clients instantly recover.
2) Soft lease at ingest — Redis SETNX for fast in-flight protection
Why: A Redis SETNX gives a fast in-memory claim so the common-case duplicate arrives sub-ms later and gets rejected or waits.
Example (pseudo-python):
# try acquire soft lease (30s)
key = f"idem:{tenant_id}:{idempotency_key}"
acquired = redis.set(key, fingerprint, nx=True, ex=30)
if acquired:
try:
response = handle_request(request)
# cache completed response for longer
redis.set(key, json.dumps(response), ex=86400)
return response
finally:
# optionally keep the completed cache; do not delete here if you want replay
pass
else:
stored = redis.get(key)
if stored == b'processing':
return HTTP_409_CONFLICT
return HTTP_200_WITH_CACHED_RESPONSE
Trade-offs:
- Fast, low-latency, but ephemeral: a Redis failover could lose keys. Treat Redis as optimization; durability must be in the DB.
- Ensure TTL > expected processing p99 to avoid stealing work from a still-active request.
3) Worker / DB layer — unique constraints + transactional outbox
Why: The only rock-solid guard is the authoritative store. Make the idempotency claim and the state change in the same transaction. Use a unique constraint on (tenant_id, idempotency_key, route) so concurrent attempts serialize and losers fail harmlessly.
Also use a transactional outbox: write the business change and the outbox row in one DB transaction so your downstream publish becomes an at-least-once loop without losing messages.
Example (Postgres SQL sketch):
BEGIN;
-- claim the idempotency key and insert business row atomically
INSERT INTO idempotency_keys (tenant_id, idempotency_key, request_hash, status, expires_at)
VALUES ($1,$2,$3,'PENDING', now()+interval '24 hours')
ON CONFLICT (tenant_id,idempotency_key) DO NOTHING;
-- If the insert did nothing, read the status and return cached result
-- Otherwise perform the business change
INSERT INTO orders (order_id, tenant_id, amount) VALUES ($4,$1,$5);
-- add outbox row in same tx
INSERT INTO outbox (id, topic, payload, created_at) VALUES ($6, 'order.created', $7, now());
COMMIT;
Worker behavior:
- If the idempotency insert conflicts, treat it as success (read previous result).
- If commit succeeds, a separate outbox dispatcher publishes and marks the outbox row as published.
Trade-offs:
- DB constraints are durable and correct but add latency and contention. SERIALIZABLE / upsert choices can increase conflicts; handle 409s gracefully.
- Outbox adds complexity but solves dual-write issues.
4) Queue dedup / Inbox — make consumers deterministic
Why: Brokers are at-least-once. Consumers must store processed message IDs (inbox) in a table with a unique index. Insert the processed id in the same transaction as the side effect so redeliveries become no-ops.
Example (consumer pseudocode):
# message has message_id
with db.transaction() as tx:
inserted = tx.execute("INSERT INTO inbox (message_id, processed_at) VALUES ($1, now()) ON CONFLICT DO NOTHING", [message_id])
if inserted.rowcount == 0:
# duplicate, skip
return
process_message(message)
# commit transaction - side effect and inbox row committed together
Kafka notes:
- Kafka offers idempotent producers and transactional writes, but that doesn't remove the need for a durable consumer-side ledger if your side effects go outside Kafka (e.g., DB writes or external API calls).
Trade-offs:
- Inbox tables grow; use TTLs, partitions, or periodic compaction.
- For very high throughput you may prefer a compacted key-value store (DynamoDB) for the ledger.
5) External calls & saga steps — deterministic per-call keys
Why: When calling third-party APIs (Stripe, ad networks, payment gateways) or coordinating sagas, generate per-step idempotency keys deterministically from durable business data and persist step status.
Guidelines:
- Derive the external key from your canonical id (e.g., idempotency_key -> external_key = sha256(tenant_id + idempotency_key + step)) so a replay regenerates the same key.
- Persist per-step status (pending, succeeded, compensated) so long-running flows reconcile reliably.
- Implement a periodic reconciler for stuck steps and compensations for negative paths.
Trade-offs:
- External API idempotency windows vary (Stripe keeps keys for 24h, some endpoints longer). Align your TTLs and reconciliation cadence to the partner’s semantics.
- If an external API is not idempotent, you must record intent and implement compensating actions.
Putting it together: trade-offs and operational decisions
- Redis leases: fast, reduce duplicate work at the gateway; ephemeral and must be backed by durable DB claims.
- DB constraints + outbox: durable and correct, but add latency and more complex migrations/monitoring.
- Inbox tables and consumer-led dedup: make at-least-once deterministic; cost and storage management matter at scale.
- Fingerprinting: prevents client bugs where the same key is reused for different payloads.
- TTL and partitioning: keep dedup stores bounded; choose TTLs longer than client retry budgets.
Design rule of thumb: fail-closed for financial or irreversible work (reject when the deduplication store is unavailable) and fail-open only for truly idempotent operations.
Conclusion
Idempotency keys are the right starting point — but they are one tool in a multi-layer defense. Combine gateway claims, Redis soft leases, transactional unique constraints with outbox, queue inboxes, and deterministic external keys. The combination converts “heroic engineering to avoid duplicates” into a systematic, observable resilience strategy.
Which layer would reduce incidents for your team this quarter? Start by instrumenting duplicate rates and add the lowest-effort layer that cuts the highest error class.
Top comments (0)