Idempotency Keys Make Retried Mutations Safe

A client can lose the result of a successful mutation without losing the mutation itself. The server may commit a payment, reservation, or job submission and then drop the connection before the response reaches the caller. From the client’s perspective, a timeout leaves two plausible states: the operation failed before commit, or it committed and only the response was lost.

Blindly retrying a non-idempotent mutation can apply the effect twice. Refusing every retry leaves the caller unable to recover from an ambiguous outcome. An idempotency key gives both sides a stable identity for one logical operation, so a repeated attempt can reuse the result of the first accepted attempt instead of creating another effect.

The key is not a retry mechanism by itself. It is a deduplication contract around a mutation.

One logical operation keeps one key

The client generates a key before the first attempt and reuses that exact key for retries of the same logical mutation.

attempt 1
POST /charges
Idempotency-Key: 8f6c...
        |
        +--- response lost

attempt 2
POST /charges
Idempotency-Key: 8f6c...
        |
        +--- same logical operation

A fresh logical operation needs a fresh key. Reusing a key across unrelated mutations can collapse distinct work into one recorded result. Generating a new key for every retry defeats deduplication because the server sees each attempt as independent.

Random UUIDs are common key material because they are easy to generate with negligible collision risk at ordinary application scales. The protocol still needs an explicit maximum length, accepted character set, and scope.

The server binds a key to request identity

A key alone is insufficient. If a client accidentally sends the same key with different parameters, returning the first result can hide a programming error.

The server can store a fingerprint of the normalized request beside the key:

key
request fingerprint
status
response metadata
created_at
expires_at

On a repeated request, the server compares the incoming mutation with the stored identity. A matching request can receive the recorded outcome. A conflicting request should be rejected rather than silently treated as equivalent.

Normalization has to be stable. Hashing raw JSON bytes can classify semantically identical objects as different when field order or insignificant whitespace changes. Systems can instead fingerprint selected canonical fields or use a deterministic serialization defined by the API contract.

Sensitive request material should not be copied into a deduplication table without need. A digest or selected identifiers can provide request binding while reducing retained data.

Claiming the key and committing the effect must align

The central race appears when two attempts carrying the same key arrive at nearly the same time.

request A ----\
               +--> same key
request B ----/

A read-then-write sequence without a uniqueness constraint is unsafe. Both workers can observe that no record exists, both execute the mutation, and only later attempt to store the key.

The deduplication claim needs an atomic boundary. A database-backed implementation can use a unique constraint on the scoped key and create the idempotency record within the same transaction as the business mutation when both live in one transactional database.

BEGIN;

INSERT INTO idempotency_records (scope, key, request_hash, state)
VALUES ('account-42', '8f6c...', 'sha256:...', 'started');

-- apply the business mutation here

UPDATE idempotency_records
SET state = 'completed', status_code = 201
WHERE scope = 'account-42' AND key = '8f6c...';

COMMIT;

The unique constraint decides which concurrent attempt owns first execution. Other attempts must inspect the existing record rather than proceed with the mutation.

When the business effect and idempotency record live in different transactional systems, a single local transaction cannot make both commits atomic. That architecture needs an explicit coordination model rather than assuming the key removes a dual-write gap.

In-progress requests need a defined response

A duplicate can arrive while the first attempt still owns the key but has not completed. Returning a cached success is impossible because no final result exists yet.

Several policies are valid:

existing state = started
    |
    +--> wait for completion
    +--> return conflict / retryable status
    +--> attach to shared in-flight work

The choice depends on request duration, server architecture, and API semantics. Waiting consumes resources and needs a deadline. Returning immediately shifts retry timing to the client. Joining in-flight work requires local or distributed coordination if callers can reach different instances.

A crashed worker also leaves a difficult started record. The system needs enough state to distinguish active ownership from abandoned work, and recovery must be compatible with the business mutation’s commit semantics. Deleting every old started row and executing again is unsafe if the effect may already have committed.

The recorded outcome is part of the contract

Deduplication is strongest when a repeated request receives the same externally relevant outcome as the first completed request. That often means storing the status code and enough response data to reconstruct the reply.

first attempt:
  execute -> 201 + resource_id=abc

retry:
  lookup  -> 201 + resource_id=abc

Not every response should be retained forever or in full. Large bodies can be replaced with a resource reference when the API can reconstruct an equivalent response. Security-sensitive headers and transient transport metadata usually do not belong in the record.

Failure policy also needs precision. A validation error produced before any mutation may be safe to recompute. A failure returned after a commit may need to be recorded because retrying execution could duplicate the effect. The storage rule should follow the point at which the operation becomes externally committed.

Scope prevents unrelated clients from colliding

The same textual key can safely exist in separate namespaces if the storage key includes the relevant owner:

(account_id, idempotency_key)

Scope can follow an account, tenant, API credential, endpoint, or another contract boundary. A global namespace is simple but allows one client’s accidental key choice to collide with another client’s request unless keys are already cryptographically random and isolated by authorization.

Authorization still applies on every attempt. Possessing a valid idempotency key must not grant access to another principal’s recorded response.

Expiration defines the retry window

Idempotency records cannot usually grow without bound. A retention period creates a finite deduplication window.

After expiration, the same key may be treated as new, so the API should state the supported retry horizon. Cleanup must also account for in-progress records and any business references required to reconstruct a response.

Retention is an operational tradeoff. A longer window consumes more storage but covers delayed retries. A shorter window reduces state but makes old retries capable of executing again. The correct duration follows the maximum retry interval the service is prepared to support.

Idempotency does not make every retry safe

A key protects only the operation covered by its deduplication boundary. Side effects emitted outside that boundary can still duplicate.

For example, a transaction may create an order and then send an email after commit. If the process crashes between those steps, replaying the request from a completed idempotency record should not send the email again as an incidental part of request handling. Durable side-effect delivery needs its own identity and delivery policy.

The same distinction applies to calls into other services. Recording a local idempotency result does not retroactively deduplicate a remote mutation that was issued without a compatible operation identity.

Metrics expose gaps in the contract

Useful telemetry separates first attempts, completed replays, conflicting payloads, in-progress duplicates, expired keys, and storage failures. These categories reveal different problems.

A rising replay count may reflect normal client retry behavior. Payload conflicts can indicate a client that reuses keys incorrectly. Persistent in-progress records can signal stalled workers or incomplete recovery. Storage errors deserve special attention because proceeding without the deduplication claim can turn an infrastructure failure into duplicate business effects.

Logs should include a safe key identifier or digest, the scope, and the state transition without exposing sensitive request bodies.

The key turns ambiguity into a stable lookup

Network failures make mutation outcomes ambiguous at the caller. An idempotency key does not remove that ambiguity from transport; it gives the server a durable place to resolve it.

The useful guarantee is narrow: within the documented scope and retention window, repeated attempts for the same accepted operation can converge on one recorded execution result. That guarantee depends on atomic claiming, request binding, explicit in-progress handling, and storage that survives the retry path.