A booking test sends the same request twice with the same idempotency key. The remote booking service is called once. Both callers receive the same successful response.
Then one field changes while the key stays the same. The remote service is still called once, but the second request receives a conflict instead of the first result.
The key did not establish sameness. It only gave the application somewhere to look.
Three values identify the operation
The retained boundary makes its decision from three values:
caller key
which operation does the caller mean?
business scope
where is that key unique?
request fingerprint
are these still the same instructions?For a cart containing several items, the scope is one item. The durable unique constraint combines that item with a hash of the caller’s key:
unique(cart_item_id, idempotency_key_hash)That choice lets a client use one key while booking several sibling items. Each item still owns a separate result. A globally unique key would force the client to manufacture a new identifier for every item, while a cart-wide key could replay one sibling’s response for another.
Scope is part of identity even when it is absent from the HTTP header.
A fingerprint detects accidental reuse
Suppose a client sends:
{
"item": "item-demo-4",
"service": "standard",
"reference": "ORDER-17"
}The booking succeeds. The client later reuses the key but changes service to
express.
Returning the earlier result would be dishonest. Sending the changed request would let one key name two operations. The retained implementation instead stores a canonical hash of the request and reports a conflict when the same scoped key arrives with a different fingerprint.
Canonicalization matters. Hashing ordinary JSON text would treat insignificant object-key order as a different request:
{"service":"standard","reference":"ORDER-17"}
{"reference":"ORDER-17","service":"standard"}The identity builder removes credentials and the idempotency key itself, then hashes a canonical representation of the remaining payload. Secrets do not become part of the comparison, and moving the key between its supported header and body forms does not change the operation.
Some mutable fields may also need exclusion. That is a business decision, not a convenient way to silence conflicts. If changing a field would change the remote side effect, it belongs in the fingerprint.
Persist the decision before the side effect
A short cache lock can prevent two workers from entering the same code at once. It cannot remember what happened after the lock expires, the process crashes, or a deployment replaces the worker.
The retained implementation acquires a durable attempt before calling the remote booking service. It locks the owning item, inspects previous attempts, and records a processing state with a lease.
request
-> acquire durable attempt
-> decide replay, conflict, wait, stop, or proceed
-> perform remote work only for proceed
-> persist result state and responseThe order is important. Recording the attempt after the remote call leaves the most dangerous interval unprotected: the provider may accept the booking while the application loses the response before writing anything locally.
The database does not make the remote operation atomic. It gives the application durable evidence that work may already have escaped.
Idempotency is a decision table
“Seen key, return old response” is too small for operations which can fail between systems.
| Stored state | Same identity arrives | Decision |
|---|---|---|
| succeeded | response is available | replay response |
| permanent failure | response is available | replay failure |
| retryable failure | failure is known not to have completed | proceed again |
| processing, lease active | another worker may still be running | report in progress |
| processing, lease expired | remote outcome may be unknown | stop and reconcile |
| outcome unknown | completion cannot be ruled out | stop and reconcile |
| same key, different fingerprint | caller changed the operation | conflict |
The useful distinction is knowledge, not HTTP status. A validation rejection received before any remote side effect may be safe to retry. A timeout after sending the booking is not. The latter could mean the provider created the shipment and only the response was lost.
Blindly retrying that timeout trades an explicit uncertainty for a possible duplicate shipment.
Reuse the result only while it remains authoritative
The retained attempt stores successful and permanent-failure responses in encrypted form. A matching request can receive that result without contacting the remote service again.
There is no arbitrary twenty-four-hour idempotency window in this boundary. The attempt belongs to the cart item and is deleted with it. The processing lease is time-bounded; the known result is not.
Those are different clocks:
processing lease:
how long may a worker plausibly still own this attempt?
result lifetime:
how long does this stored answer remain authoritative for the item?A payment API may need a fixed retention period. A document export may be replayable forever. A mutable quote may become invalid within minutes. Choosing a popular TTL without considering the business resource turns a correct replay into stale data.
If the stored response cannot be decrypted or decoded, the retained boundary does not guess. It treats the outcome as unknown.
A legacy fallback needs the same caution
Durable attempts begin at a point in time. Existing records may already show that an item shipped without having a corresponding attempt row.
The retained implementation checks the item’s terminal state and reconstructs a bounded response from stored metadata instead of sending it again. This supports records created before the new boundary.
That fallback is deliberately narrower than replaying an arbitrary old request. It relies on a durable business fact—the item is already shipped—and returns only fields retained with that item.
Migration compatibility belongs in the decision table. Otherwise the first retry after introducing idempotency can duplicate the very operations created before it.
Test every decision against the remote-call count
Response assertions alone are weak here. A duplicate remote call could return the same tracking number and still pass.
The retained tests pair each response with the number of remote invocations:
successful replay -> one call
changed request conflict -> one call
permanent failure replay -> one call
known retryable failure -> two calls
unknown outcome retry -> one callThey also prove that the same key may identify separate operations for separate items. The public fixture executes the same identity and state decisions with synthetic data.
An idempotency key is useful because callers can repeat it. Safety comes from the contract around it: a declared scope, a request fingerprint, durable state, and an honest decision when the previous outcome is unknown. Without those parts, the key is merely a string attached to hope.