Idempotency Keys Bound Duplicate Mutations Across Retries
A client can lose the response to a successful mutation. The server may commit a payment, reservation, or job submission and then lose the connection before the response reaches the caller. From the client side, timeout does not reveal whether the mutation failed before commit or succeeded before the response disappeared.
A retry is necessary for availability, but a blind retry can repeat the side effect. An idempotency key gives both attempts a stable identity so the server can treat them as one logical operation.
attempt 1: POST /orders key=8b7a... -> commit -> response lost
attempt 2: POST /orders key=8b7a... -> return stored outcomeThe key does not make an arbitrary handler idempotent by itself. Correctness comes from the storage protocol that claims the key, binds it to a request, records the operation state, and prevents a competing attempt from executing the same mutation independently.
The key names a logical operation
A fresh key belongs to one intended mutation, not to a transport attempt. Retries reuse it. A separate mutation receives a different key even when its payload happens to be identical.
This distinction avoids accidental deduplication of legitimate repeated actions. Two purchases of the same item can have identical request bodies while still representing two intended orders.
Keys are commonly generated by the client from a large random space such as UUIDv4. The server should scope them explicitly, for example by tenant or account, so one caller cannot collide with another caller’s operation namespace.
dedupe identity = (account_id, idempotency_key)The server also needs a retention policy. A key cannot suppress duplicates after its record has expired. API documentation should therefore state the period during which replay with the same key is supported.
Claiming the key and mutating state need one correctness boundary
A check followed by a separate insert is vulnerable to races.
request A: lookup key -> absent
request B: lookup key -> absent
request A: perform mutation
request B: perform mutationBoth requests observed the same empty state. The system needs an atomic claim, usually through a unique constraint, conditional write, compare-and-set operation, or transaction that makes only one contender the owner.
A relational design can place the idempotency record and business mutation in one transaction when both live in the same database.
BEGIN;
INSERT INTO idempotency_requests(account_id, key, status)
VALUES ($1, $2, 'in_progress');
INSERT INTO orders(account_id, ...)
VALUES ($1, ...);
UPDATE idempotency_requests
SET status = 'completed', response_code = 201, response_body = $3
WHERE account_id = $1 AND key = $2;
COMMIT;A unique constraint on (account_id, key) rejects a second owner. The exact schema varies, but the invariant is stable: no two contenders may independently cross the side-effect boundary for the same dedupe identity.
Reusing a key with different input must fail
A key should not silently alias two different operations. The server can store a canonical request fingerprint alongside the key and compare later submissions against it.
key: 8b7a...
fingerprint: SHA-256(canonical operation fields)Only fields that define the logical operation should participate. Transport metadata such as tracing headers normally does not belong in the fingerprint.
Canonicalization must be deterministic. Hashing raw JSON bytes can treat harmless formatting or object-key ordering differences as different requests. A structured canonical representation, or an application-specific selection of normalized fields, avoids that ambiguity.
When an existing key arrives with a different fingerprint, the safe result is a conflict response rather than replaying an unrelated stored outcome.
In-progress attempts need an explicit policy
Concurrent retries can arrive while the first request still owns the key. Returning a cached result is impossible because no final result exists yet.
The service can wait briefly for the owner, return a retryable conflict, or expose an operation status resource. The choice depends on latency budgets and API semantics, but it should not allow the second request to execute the mutation.
key absent -> claim and execute
key in_progress -> wait, conflict, or report pending
key completed -> replay recorded outcomeAn in_progress record can also outlive a crashed worker. Recovery needs enough durable state to distinguish an abandoned pre-commit attempt from a mutation that committed elsewhere. Simply deleting old in-progress rows can be unsafe if the side effect may already exist.
The stored outcome is part of the contract
For a completed key, replay normally returns the outcome of the original logical operation rather than executing the handler again. Storing only a marker such as done=true may be insufficient when the client needs the created resource identifier, response status, or other stable result.
Not every response byte has to be retained. A record can store a resource ID and reconstruct a current representation if the API contract permits that. Another API may require replay of the original status and selected response fields.
Errors need classification as well. A validation error that occurs before any side effect can often be returned without consuming the key. A deterministic failure after the key is claimed may be worth recording. A transient infrastructure error before commit may leave the operation eligible for another attempt. These states should follow the service’s actual commit boundary rather than a blanket rule for all non-2xx responses.
External side effects require a wider protocol
A database transaction cannot atomically cover an unrelated payment processor, email provider, or message broker unless those systems participate in the same transaction protocol. Marking the key completed before an external effect can lose the effect; performing the effect first and crashing before recording completion can cause a retry to repeat it.
The boundary must therefore extend through another mechanism. An outbox can atomically record the local mutation and an event, then deliver that event separately. A downstream API can accept the same idempotency identity. A workflow can persist step state and make each externally visible step duplicate-safe.
API request
|
+--> local transaction
|-- idempotency record
|-- business state
`-- outbox event
|
v
async deliveryEnd-to-end duplicate suppression is only as strong as the narrowest side-effect boundary. A protected HTTP endpoint does not prevent duplicate downstream effects if the worker discards the operation identity.
Idempotency does not mean exactly-once transport
Networks can duplicate, delay, and lose messages. Clients can retry after timeouts. Workers can restart after partial progress. Idempotency keys do not remove those behaviors and do not create exactly-once delivery.
They provide a stable identity that lets a service make repeated delivery converge on one logical effect within a defined scope and retention period. The transport may still deliver several attempts.
This framing keeps operational expectations precise. Metrics should distinguish logical operations from physical attempts and should expose key conflicts, in-progress collisions, replay counts, expired-key retries, and failures around the commit boundary.
The useful guarantee is narrow and testable
A sound implementation can state its guarantee in concrete terms: for a given caller scope, idempotency key, matching operation fingerprint, and supported retention window, concurrent or sequential retries do not independently execute the protected mutation.
That guarantee depends on atomic ownership, durable operation state, strict payload binding, deliberate recovery for unfinished work, and propagation of the operation identity across every side-effect boundary that needs duplicate suppression.
With those pieces in place, retry becomes a recovery mechanism for ambiguous outcomes without turning a lost response into an accidental second mutation.