Idempotency & Delivery

What Counts as the Same Operation?

Deduplication rests on a claim that two requests are "the same". That claim is made across a machine boundary, using only what the caller sent, by a store that has its own partitioning, retention and failure domain. Every one of those is a scope boundary, and a duplicate slips through wherever two of them disagree.

▶ Run the lab

The question this answers

The question

Two requests arrive carrying the same key. Under what conditions is that enough to conclude they are the same operation?

The guarantee — the property claimed, and its scope

At most one effect per (namespace, key, request fingerprint) — within the dedup record’s retention window, and within the consistency and failure scope of the store that holds it. Outside any of those three qualifiers there is no guarantee at all. Removing a qualifier from that sentence is how teams end up believing they have exactly-once behaviour that they do not have.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

The receiver knows the bytes of this request and whatever it has retained about previous ones. It does not know the caller’s intent, whether the caller reused a key deliberately or by accident, whether a different replica of itself has seen this key, or whether it once saw this key and has since forgotten. "I have not seen this key" and "this key does not exist" are different statements, and the receiver can only make the first.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
idempotencyidentityscoperetentionmulti-tenancy

Identity is a claim, not an observation

The receiver cannot observe that two requests are the same operation. It can only observe that they carry the same token, and then *decide* to treat that as identity. Everything difficult here follows from the gap between those two things — because the caller and the receiver are different machines with different beliefs about what the token means.

The caller believes: "this key names the operation I am attempting; my retries carry it; nobody else uses it." The receiver believes: "this key names an operation I may already have applied; if I have, I return what I returned before." Those two beliefs only line up if there is an agreement, made at design time, about the key’s namespace, its lifetime, and what happens when the same key arrives with different content. Nothing in the protocol enforces that agreement, which is why it fails silently.

This is the reason this lesson is not the same as the mechanism. Storing a key and replaying a response is a small amount of code. Deciding what the key *means* across two organisations, three regions and a retention policy is a distributed-systems design problem, and it is where the real bugs live.

DimensionThe questionFailure when unscoped
NamespaceprotocolSame key, different tenant — same operation?Tenant B receives tenant A’s stored response
Body bindingprotocolSame key, different body — same operation?A real second order silently returns the first order’s receipt
EndpointassumptionSame key on a different route — same operation?A refund returns a charge’s stored response
RetentionassumptionSame key, four days later — same operation?A DLQ replay charges the customer again
Failure scopeassumptionSame key, other region — same operation?Every in-flight retry duplicates during failover
Outcome scopeassumptionSame key after a failed attempt — same operation?A transient failure is cached and the caller can never succeed
The scope dimensions, and what a duplicate looks like when each is wrong

Namespace: the key belongs to someone

A key is a string a client chose. Clients choose badly. They use 1, test, order-1, an auto-incrementing integer, or a UUID that a badly-seeded random generator repeats. Two different customers of your API will collide, and when they do, an unscoped dedup store returns one tenant’s stored response to another tenant’s request.

That is not a correctness bug in the ordinary sense — it is a data disclosure. The stored response may contain an order id, an amount, an address. The remedy is trivial and must be non-optional: the dedup record is keyed by (tenant_id, api_key_id, endpoint, client_key), never by client_key alone, with the tenant taken from the authenticated principal and never from the request body (Security owns the general form of this rule under tenant isolation).

The endpoint component deserves its own thought. If a client reuses one key across POST /charges and POST /refunds, should the second return the first’s response? Almost certainly not — but "almost" is doing work, because some designs deliberately scope a key to a whole logical operation spanning several endpoints. Decide explicitly and document it, because the client cannot infer it.

1dedup_key = hash(
2 tenant_id, # from the authenticated principal, NEVER the body
3 api_key_id, # two credentials of one tenant are different callers
4 endpoint, # a key on /charges must not answer /refunds
5 client_key, # the caller's own identifier
6)
7
8record = {
9 dedup_key,
10 request_fingerprint: sha256(canonical(body)), # body binding
11 status: IN_PROGRESS | COMPLETED | FAILED_PERMANENT,
12 response, # replayed on a match
13 created_at,
14 expires_at, # retention — and the boundary of the guarantee
15}
Scoping the record, and what each component defends against

Body binding: same key, different body

A client sends key K with a €50 charge. It later sends key K with a €500 charge — a bug in their retry loop, a reused variable, a form resubmitted after an edit. Three policies exist and only one is safe.

Ignore the body and replay the first response. The client believes it charged €500. You charged €50 and told them it worked. This is the worst option because it is silent and the divergence is between your record and their belief, which nothing will reconcile.

Treat it as a new operation. Then the key means nothing, and a genuine retry with a body that differs by a timestamp field will double-charge. Also silent.

Reject it. Store a fingerprint of the canonicalised request body with the record; if a request arrives with a matching key and a non-matching fingerprint, return a definite error (Stripe returns a 400 with idempotency_key_in_use semantics; the exact code matters less than that it is a 4xx and not a replay). The client learns immediately that it has a bug, and no wrong effect occurs. This is the only option where the failure is loud.

Canonicalisation is the subtle part: JSON key order, absent versus null fields, numeric formatting and a client-side timestamp in the body will all change the hash without changing the operation. Fingerprint a canonical form and exclude fields that legitimately vary between attempts, or you will reject correct retries — which teaches clients to generate a fresh key per attempt, which destroys the mechanism entirely.

Retention: the guarantee has an expiry date

The dedup store cannot grow forever. Records must expire, and the moment a record expires, a request carrying that key becomes a new operation. So the guarantee is not "at most once" — it is "at most once within the retention window", and the window must be chosen against the maximum possible retry horizon, which is almost always longer than anyone estimates.

The client’s own retry loop might last thirty seconds. But a message sitting in a dead-letter queue may be replayed by an operator on Monday morning after failing on Friday night. A webhook sender may retry with exponential backoff for three days. An operator may click "re-drive" in an admin console a week later. A batch job may be re-run from a checkpoint. Each of those presents the original key long after any client-side retry would have given up.

So the rule is: retention ≥ the longest path by which the same key can legitimately reach you again, plus margin. Twenty-four hours is a common default and is too short for any system with a manual DLQ workflow. And because retention is finite, expiry must be observable: a metric for eviction rate and for the age of the oldest retained record, because an eviction is a future duplicate you have already accepted.

The storage cost is real and is the honest counter-pressure. A high-throughput API storing a fingerprint and a response body per operation for seven days is storing a lot. The usual compromise is to keep the *claim* (key plus fingerprint plus status) for a long window and the *response body* for a short one — a replay after the body expires can then return a definite "this operation was already applied" rather than the original payload, which is far better than treating it as new.

path                              max age of a legitimate replay
--------------------------------  ------------------------------
client library retry loop                                   30s
gateway retry                                                5s
async worker + backoff                                      15m
webhook sender (3rd party)                                3d 0h
dead-letter queue, manual drain           (unbounded — policy: 7d)
operator "re-drive" in admin UI           (unbounded — policy: 7d)
--------------------------------  ------------------------------
required retention                        7d + margin  =>  10d

# Current setting: 24h.  Every DLQ drain older than a day
# is a duplicate the system will not catch.
Retention chosen against the actual retry horizon

The genuinely distributed part: where the record lives

The dedup store is itself a distributed system, and its topology decides whether the guarantee survives the events that produce the most retries.

Co-location with the effect. If the claim and the effect commit in one transaction in one store, the claim is exact. If the claim lives in Redis and the effect in Postgres, there is a window: crash after claiming and before committing, and the operation is now permanently blocked (the claim says done, the effect never happened); crash after committing and before claiming, and the retry duplicates. Which of the two you get is decided by the order, and neither is good. Separate stores buy latency and cost correctness.

Partitioning. The dedup store is partitioned by something. If the retry routes to a different partition than the original — because the partition function includes a field that changed, or because the routing layer rebalanced — the check finds nothing. Dedup state must be partitioned by the dedup key itself, so every attempt with that key lands on the same shard (Hash Partitioning and the Modulo Trap).

Failure domain. This is the sharp one. A regional failover is exactly the event that produces a burst of retries — clients time out, retry, and get routed to region B. If the dedup store is regional and not replicated synchronously, region B has never seen any of those keys. Every in-flight operation duplicates, precisely at the moment the system is least able to cope. The dedup record must be in the same failure domain as the effect: if the effect fails over, the claim must fail over with it, atomically. A globally replicated dedup store with asynchronous replication is worse than useless here, because the replication lag is measured in exactly the window the retries occupy (EU and US Are Partitioned. Can Both Keep Accepting Writes?, Three Ways to Accept a Write in More Than One Place).

Failover: the retry lands where the claim never replicatedassumption
ClientRegion A (effect + dedup) is down over this spanRegion A (effect + dedup)Region B (dedup replica, lagging)POST /charges key=K: deliveredPOST /charges key=K200 OK: sent, never arrives — dropped in flight200 OKdropped — never arrivesasync replicate claim K: sent, never arrives — dropped in flightasync replicate claim Kdropped — never arrivesPOST /charges key=K (retry): deliveredPOST /charges key=K (retry)claim K, charge committed (write) at t=3claim K, charge committedregion A fails (crash) at t=6region A failstimeout — retries with key K (decide) at t=9timeout — retries with key KK not found locally → charges again (write) at t=13K not found locally → charges againt=0time →t=13
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritecrashdecide
The claim existed and was durable — in a region that is gone. Asynchronous replication of the dedup record means the guarantee evaporates during exactly the incident that generates the retries. Scope the claim to the same failure domain as the effect, or state plainly that the guarantee is per-region.

Outcome scope: which failures are worth remembering

A claim has a status, not just an existence. Three cases behave differently and conflating them causes real incidents.

In progress. The first attempt is still running when the retry arrives. Returning "already applied" is wrong — nothing has been applied yet. The correct answer is a definite "in progress, retry later" (a 409, or a 425), because the caller must not conclude success and must not start a third attempt that races the first two.

Completed. Replay the stored result. This is the case everyone implements.

Failed. Now it matters *why*. A deterministic failure — validation error, insufficient funds, unknown account — will fail identically on every retry, and caching it saves work and gives the client a consistent answer. A transient failure — a downstream timeout, a lock conflict, a 503 — must not be cached, because caching it burns the key: every retry replays the failure and the client can never succeed with that operation identity, even though the operation is perfectly valid. The client’s only escape is to generate a new key, which is precisely the behaviour that reintroduces duplicates.

So the rule is: cache deterministic failures, release the claim on transient ones. Getting this wrong produces a support case that reads "your API returns the same error forever for this order", and the cause is three layers away from where anyone looks.

  • IN_PROGRESS → 409/425, do not execute, do not report success.
  • COMPLETED → replay the stored response.
  • FAILED (deterministic) → replay the failure; the retry cannot succeed anyway.
  • FAILED (transient) → release the claim so a retry can genuinely re-attempt.
  • EXPIRED → treat as new, and count it, because it is a duplicate you chose to accept.

Key points

  • Identity across a machine boundary is a design agreement, not an observation — the receiver only sees a matching token.
  • Scope the record by tenant, credential and endpoint, taken from the authenticated principal; an unscoped key is a cross-tenant disclosure.
  • Bind a canonical fingerprint of the body to the key and reject mismatches loudly; replaying or re-executing are both silent failures.
  • Retention must exceed the longest legitimate replay path — DLQ drains and operator re-drives, not just the client’s retry loop.
  • The claim must live in the same store, the same partition and the same failure domain as the effect.
  • A regional failover produces a burst of retries and is exactly when a regional dedup store fails to help.
  • Claim status matters: in-progress is not completed, and caching a transient failure burns the key permanently.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • The caller generates a key before its first attempt and reuses it for every retry of that operation.
  • The receiver derives a scoped dedup key from the authenticated tenant, the credential, the endpoint and the client key.
  • It computes a canonical fingerprint of the request body, excluding fields that legitimately vary between attempts.
  • It atomically claims the scoped key with status IN_PROGRESS in the same store as the effect.
  • On a claim conflict it compares fingerprints: mismatch means reject; match means act on the recorded status.
  • On completion it records the outcome and response against the claim, in the same transaction as the effect.
  • Records expire on a schedule chosen from the longest replay path, and evictions are counted as accepted future duplicates.
What can fail at the boundary
  • Two tenants choose the same key and an unscoped store cross-wires their responses.
  • A retry carries a body that differs in an irrelevant field, so the fingerprint mismatches and a valid retry is rejected.
  • A retry carries a materially different body and the receiver replays the old response.
  • A record expires before a DLQ replay arrives, so the replay executes as a new operation.
  • A regional failover routes retries to a region whose dedup replica has not caught up.
  • The claim store and the effect store are different and a crash lands between them.
  • A transient failure is cached, permanently burning a valid key.
  • The claim is left IN_PROGRESS forever because the process died, blocking all future retries of that operation.
How it fails — what an operator sees
  • Cross-tenant response leak: a customer reports seeing another company’s order id in an API response. The dedup store was keyed by the client-supplied string alone, and two tenants both used order-1. This surfaces as a security incident, not a reliability one.
  • Duplicate charges concentrated in a failover window: the operator sees a spike of duplicates whose timestamps cluster exactly around a regional failover, and none at any other time. The dedup store was regional with asynchronous replication.
  • Monday-morning duplicates: a DLQ drained by an operator produces effects that were already applied on Friday. The duplicate rate has a weekly shape, which nobody connects to a 24-hour retention setting.
  • Permanently stuck operation: a client retries the same key forever and always receives the same 503. The claim cached a transient failure; the client cannot succeed and cannot know why.
  • Silently swallowed second order: a customer places two genuinely different orders and receives one confirmation. The client reused a key and the receiver replayed rather than rejecting. Neither side logs an error.
  • Rejected valid retries: the fingerprint includes a client-side timestamp, so every retry mismatches and is rejected. Clients respond by generating a fresh key per attempt, and the duplicate rate quietly rises.
Where coordination is required
  • The claim is a coordination point on every write, and its scope determines how much coordination you are buying: same-transaction is free, same-region is cheap, cross-region is expensive and forces a choice between latency and correctness during failover.
  • A globally consistent dedup store means every write pays a cross-region round trip — the direct application of Coordination Couples Availability to what looks like a local concern.
  • Namespace and fingerprint rules are coordination at design time between API provider and consumer, with no runtime enforcement, which is why they must be documented as part of the contract.
  • Retention is a coordination decision with operations: the dedup window must be at least as long as the operational processes that can replay a request.
What still holds under failure
  • Within the retention window and the store’s consistency scope, the at-most-once property holds through crashes and retries.
  • Outside the window, or across a failure-domain boundary the record did not cross, the property simply does not hold, and nothing signals that it has lapsed.
  • A claim left IN_PROGRESS by a crashed process blocks retries until it is reaped, converting a duplicate risk into an availability problem for that operation.
  • The effect itself remains correct and durable throughout; what degrades is the receiver’s ability to recognise a repetition.
How it recovers
  • Detect: count evictions, expiries and fingerprint mismatches. Each is a distinct signal and each maps to a distinct bug class.
  • Contain: reap stale IN_PROGRESS claims on a timeout longer than the maximum handler duration, so a crashed attempt does not block the operation forever.
  • Recover: for duplicates that did occur, dedupe downstream by a natural business key, which does not depend on the claim having survived (Deduplication: Bounded Memory Against an Unbounded Stream).
  • Reconcile: compare operations against effects per tenant and per window; the duplicate population is bounded and identifiable by the shared client key.
  • Verify: after any failover, explicitly measure the duplicate rate in the failover window rather than assuming the mechanism held.
How you would know
  • Dedup hits split by outcome: replayed-completed, rejected-fingerprint-mismatch, blocked-in-progress, replayed-failure. One counter for "dedup hits" hides all the interesting cases.
  • Eviction and expiry rate, plus the age of the oldest retained record — evictions are accepted future duplicates and should be a deliberate number.
  • Store size and growth rate against the retention policy, so the cost of the window is visible when someone proposes shortening it.
  • Duplicate effect rate measured independently at the sink by natural key, which is the only check that does not trust the dedup mechanism.
  • Cross-region claim replication lag, if the store is replicated — the direct measure of the failover exposure window.
  • Count of stale IN_PROGRESS claims reaped, which distinguishes crashed handlers from ordinary contention.
When it helps
  • Public APIs where the caller is a third party whose retry behaviour you cannot see or control.
  • Payment and ledger operations, where the cost of a duplicate justifies a durable record per operation.
  • Multi-tenant platforms, where the namespace question is not optional and getting it wrong is a disclosure.
  • Systems with operator-driven replay tooling, where the retry horizon is days and only an explicit retention decision covers it.
When it hurts
  • For operations that are already naturally idempotent, where a dedup store adds cost, latency and a dependency for no gain.
  • At very high throughput on low-value operations, where the storage and write amplification exceed the cost of the duplicates being prevented.
  • When the store is placed outside the effect’s transaction for convenience, converting an exact guarantee into a probabilistic one while keeping the name.
  • When teams state the guarantee without its qualifiers and downstream systems are built assuming exactly-once behaviour that only holds for 24 hours, in one region, for identical bodies.
Simpler alternatives
  • Dedupe by a natural business key with an upsert, which needs no separate store and no retention policy at all (Deduplication: Bounded Memory Against an Unbounded Stream).
  • Make the operation naturally idempotent so identity does not need defining (Idempotent Is a Property of the Whole Effect, Not the Write).
  • Use a monotonic per-client sequence number instead of an opaque key, so the receiver stores one watermark per client rather than one record per operation — far cheaper, at the cost of requiring ordered submission.
  • Push identity down to the effect: a unique constraint on (tenant, client_reference) in the target table, which is exact, free and automatically scoped to the effect’s failure domain.
  • Accept duplicates and reconcile, where the business impact is small and the machinery is not worth its operational surface.

At most one effect per (namespace, key, fingerprint), within a window, within one store

At most one effect per (namespace, key, fingerprint), within a window, within one store
Two requests arrive carrying the same key. Drop any qualifier from that sentence and it stops being true — pick which one to drop.
the two requests
the receiver
the environment
✓ namespace✓ key✓ request fingerprint✓ retention ≥ 0.02 h✓ one consistency scope
outcome
deduplicated
replay comes back after
0.02 h
qualifiers holding
5/5
errors logged
0
Same namespace, same key, same fingerprint, inside the retention window, inside one consistency scope. All four qualifiers hold, which is exactly when the guarantee applies.
client retry (seconds) — the case every dedup window covers. Storing keys forever is not the answer either: storage grows with total operations rather than with concurrency, and the cost becomes the dominant one. The real decision is retention against your longest legitimate replay path, plus a metric that counts what you evict, so you find out before your customers do.
assumptionEvery outcome here holds only under the stated scoping. The failure modes are what happens when one of the five qualifiers below is quietly false.

What people believe, and what is true

Claim

The idempotency key guarantees the operation happens at most once.

Reality

It guarantees at most once per scoped key, per fingerprint, within a retention window, within one consistency scope. Drop any qualifier and the sentence becomes false.

Claim

Same key means same operation.

Reality

Only if you also agreed on the namespace and the body. A key from another tenant, or the same key with a different amount, is not the same operation and must not be treated as one.

Claim

We can just store keys forever to be safe.

Reality

Storage grows with total operations, not with concurrency, and the cost becomes the dominant one. The real decision is retention against the longest legitimate replay path, plus a metric for what you evict.

Claim

Our dedup store is replicated globally, so we are covered during failover.

Reality

If replication is asynchronous, the records lost are exactly the recent ones — and the recent ones are the ones being retried. Async replication does not cover the failover case at all.

Claim

If the key exists, return the stored response.

Reality

Not if the first attempt is still running (return in-progress), and not if the stored outcome was a transient failure (release the claim). Conflating the three states creates both duplicates and permanently stuck operations.

Claim

Rejecting a body mismatch is unfriendly; better to just process it.

Reality

Processing it either double-charges or silently drops a real operation. A loud 4xx tells the client about a bug in their code, immediately, at zero cost to your data.

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

Deduplication needs a definition of "same". Scope the key by tenant and endpoint, bind it to the request body, keep it long enough to cover every way a replay can reach you, and store it where the effect lives.

Practical

Key the record on (tenant, credential, endpoint, client key) with the tenant from the authenticated principal. Store a canonical body fingerprint and reject mismatches with a 4xx. Track claim status — in-progress, completed, deterministic failure, transient failure — and treat each differently. Set retention from the longest replay path in your operations, and alert on evictions.

Advanced

The scope that matters most is the failure domain. A dedup record that does not fail over atomically with the effect provides no guarantee during a failover — which is the event that produces the retries. Either co-locate the claim with the effect in one transactional store that fails over as a unit, or accept and document a per-region guarantee and dedupe again at a global sink by natural key. Synchronously replicating the dedup store across regions is the third option and it prices every write at a cross-region round trip, which is usually the wrong trade for the same reason multi-region synchronous writes usually are.

Apply it

Build it, then break it
  • 🔧 Audit your dedup key derivation. Confirm the tenant comes from the authenticated principal and not from anything the caller can set.
  • 🔧 Compute the longest legitimate replay path in your system — including manual DLQ drains — and compare it with your current retention setting.
  • 🔧 Simulate a failover with an asynchronously replicated dedup store and measure the duplicate burst as a function of replication lag.
Reason about this
  • Duplicate charges appear only in a two-minute window every few months, always at the same time as an infrastructure event. Explain.
  • A partner integration reports that one of their orders "disappeared" — they got a success response containing a different order’s id. Walk through the cause.
  • A client complains that a specific operation has returned the same error for three days and they cannot get past it. What is happening?
Interview questions
  • 💬 A request arrives with a key you have seen and a body you have not. What do you do, and why are the other two options wrong?
  • 💬 How long should an idempotency record be retained, and how would you derive the number?
  • 💬 Your dedup store is regional. Describe what happens during a regional failover.
  • 💬 Two tenants both use the key "order-1". What breaks?
  • 💬 The first attempt is still executing when the retry arrives. What should the receiver return?
  • 💬 Why is caching a 503 against an idempotency key harmful?