The charge can commit after the response disappears.
One database transaction creates one durable history.
External providers must receive the same stable key.
01 / The naive solution
The Obvious Handler Charges Every Request
The schema for a payment endpoint almost writes itself. Receive an amount. Create a charge. Return its identifier. The handler can be small enough to feel finished before the interesting failure has even been considered.
Start with the sympathetic version. One call to the handler creates one ledger entry and returns it. There is no obvious bug in the local control flow.
PaymentResponse charge(PaymentRequest request) {
String chargeId = ledger.createCharge(request);
return PaymentResponse.created(chargeId);
}
The arithmetic also looks harmless when requests and intentions remain aligned.
one request
↓
one ledger write
↓
one response
But HTTP requests are delivery attempts, not business intentions. Once those two concepts separate, the function has no memory that it has already done the work.
02 / Ambiguous delivery
The Failure Happens After Success
Alice confirms a purchase from a train. The payment service commits the charge. Before its response crosses the mobile network, the connection disappears. Her phone sees a timeout, but a timeout cannot say whether the server acted.
Alice → payment service → ledger: charge committed
Alice ← payment service: response lost
Alice → payment service: retry
The second request is indistinguishable from a second purchase to the naive handler. It performs its only behavior again.
client knowledge: unknown
server state: charge exists
safe to retry?: impossible to infer from silence
Stripe’s engineering article on robust APIs describes this exact ambiguity: an operation can complete even when its result never reaches the client. The dangerous part is not failure itself. It is disagreement about whether failure happened.
03 / Request semantics
A Retry Is a Question, Not a New Command
The useful interpretation of a retry is: “What happened to the operation already named by this request?” The naive server instead hears: “Please perform this operation now.” An idempotency key changes the grammar of the conversation.
without a key: perform a payment
with a key: perform or recall payment <order-key>
The client must keep the key stable across attempts. It should generate a new key only for a genuinely new intended payment.
POST /payments
Idempotency-Key: order-cart-alpha
amount_cents=<amount>¤cy=usd
The key is not a request identifier assigned by the server. It is a name chosen by the client for one unit of intent, carried into every uncertain retry.
04 / Stable identity
One Key Names One Intention
A strong key can be random, or derived from a durable business identifier such as a cart. Stripe suggests UUID style random values and documents that keys can be up to 255 characters. The uniqueness mechanism matters less than the boundary it names.
order-cart-alpha → charge this cart in usd
order-cart-beta → charge a different cart in usd
The mapping must not drift. If the caller sends the same key with a different amount, accepting it would make the stored answer lie about the new request.
same key + same payload → replay is safe
same key + different payload → reject the misuse
different key → a new intention
That is why the server stores a request fingerprint beside the result. A key identifies the operation. The fingerprint proves that later attempts still describe it.
05 / Production protection
Production Commits the Charge and Result Together
If the service owns the ledger database, the protection is one database transaction. The transaction claims the unique key, creates the charge, and records the response. Either every fact commits or none does.
BEGIN;
INSERT INTO idempotency_keys(key, fingerprint, state)
VALUES (:key, :hash, 'processing');
INSERT INTO charges(id, amount_cents, currency)
VALUES (:charge_id, :amount, :currency);
UPDATE idempotency_keys
SET state = 'completed', status = 201, body = :response
WHERE key = :key;
COMMIT;
The key column must be unique. A concurrent insert loses before it can create another charge. The losing request reads the committed row and returns its result.
transaction commits → charge exists + result exists
transaction rolls back → neither fact exists
state the design forbids → charge exists alone
This is the protection Redis reservation alone cannot provide when Redis and the ledger are separate systems.
06 / External charging
An External Provider Must Receive the Same Key
Sometimes the service does not own the final charging transaction. Stripe or another provider does. The local service cannot wrap its database and the provider ledger in one transaction, so it must carry the same stable idempotency key across that boundary.
POST /v1/payment_intents
Idempotency-Key: order-cart-alpha
amount=<amount>¤cy=usd
The provider recognizes repeated attempts as one named operation. The local database stores that provider result under the same key so later requests can replay it without calling the provider again.
owned ledger → charge + result in one database transaction
external provider → forward the same key + persist provider result
The stable identity must survive every hop that could repeat the business effect.
07 / Durable replay
Replays Return the Same Answer
Preventing a second charge is necessary but insufficient. A retrying caller still needs a definitive answer. The completed record therefore stores the status and body produced by the winning attempt.
{
"fingerprint": "<request-hash>",
"status": "created",
"body": "<winning-response>"
}
The second caller does not manufacture a fresh success. It receives the recorded result of the first execution.
<stored-status-line>
Idempotent-Replayed: true
<stored-response-body>
Stripe documents the same broad behavior: it saves the first result for a key and returns that result to subsequent matching attempts, even when the stored result represents a 500 error. A retry is a replay, not an instruction to improve history.
08 / Payload integrity
Payload Fingerprints Prevent Key Reuse
The demo canonicalizes the amount and currency, then hashes that representation. The specific hash is less important than stable serialization. If two workers serialize equivalent requests differently, they can create false mismatches.
canonical request = amount + ":" + lowercase(currency)
fingerprint = SHA-256(canonical request)
On a matching replay, the stored body is safe to return. On mismatch, the service refuses to reinterpret the existing key.
{
"error": "idempotency_key_reused_with_different_payload"
}
Production canonicalization must cover every parameter capable of changing the business effect. Omitting a field from the fingerprint is equivalent to pretending that field does not matter.
09 / Concurrency policy
Concurrent Duplicates Need an Explicit Policy
The demo makes concurrent duplicates wait for the owner, then replays the completed result. This makes the local property easy to observe.
50 demo callers → one owner → one charge
↘ 49 waiters → one stored response
That behavior should not be attributed to Stripe. Stripe’s API reference says a request that conflicts with another request executing concurrently is not saved as an idempotent result because endpoint execution did not begin. The caller can retry after the conflict.
this demo: concurrent duplicate → wait, then replay
Stripe docs: concurrent conflict → retryable conflict
shared rule: one key names one operation
The transport policy can differ while the business invariant remains the same.
10 / Retention contract
Expiration Reopens the Door
Remembering every key forever would create unbounded storage. Stripe documents a 24 hour window for safe retries and explains that keys expire after that period. A reused key outside its retention window can name a new execution.
inside retention: same key → recall old result
after expiration: same key → no record exists
This is a contract, not housekeeping trivia. Clients must keep their retry horizon inside the server’s retention horizon.
If a client can wake after the record has expired and retry blindly, idempotency no longer distinguishes the old intention from a new request carrying a recycled name.
11 / Demo boundary
Redis Coordinates the Demo, Not the Ledger
The repository uses an atomic in memory store by default and includes a Redis adapter using SET NX PX. Those stores can choose one owner and hold a response. They cannot automatically join a transaction in an unrelated payment ledger.
fact A: the charge exists in the ledger
fact B: the idempotency result exists in Redis
When those facts live in separate systems, conditional reservation narrows the race but leaves a crash gap.
separate stores → crash gap → reconciliation work
↑ ↓
└────── lost certainty ────────┘
Redis is useful coordination. It is not a substitute for an atomic business boundary.
12 / Honest failure
The Crash Window Defines the Demo’s Limit
The most important adversarial test crashes after the demo ledger creates the charge but before the response is stored. A retry acquires the abandoned key and creates another charge.
reserve → charge committed → CRASH → result not stored
retry → reserve again → second demo charge
The passing test deliberately reports two charges in this broken ordering.
SETNX prevents simultaneous owners.
SETNX does not atomically commit an external side effect.
That test documents a limit. It does not prove production crash safety. Production needs the shared database transaction or stable provider key described earlier.
13 / Demo evidence
The Demo Measures One Property
The repository fires real concurrent HTTP requests and counts local business effects.
mode requests successes charges replays
naive 50 50 50 0
idempotency_key 50 50 1 49
Those values came from the companion repository’s benchmark.csv. Every caller received a successful response. The idempotent demo created one charge and replayed the same body to the other callers.
demo property: created_charges(idempotent burst) = 1
demo limit: crash after ledger write can duplicate
This is evidence for the demo’s concurrent reservation behavior. It is not evidence that two separate production systems commit atomically.
14 / Build one
Build the Dumb Version Yourself
The simplified handler remains small. It reserves the key, branches on stored state, and lets only the owner call the demo ledger.
Decision decision = store.reserve(key, fingerprint, ttl);
return switch (decision.state()) {
case COMPLETED -> replay(decision.response());
case MISMATCH -> rejectKeyReuse();
case IN_PROGRESS -> awaitOwner(key, fingerprint);
case ACQUIRED -> chargeAndStore(decision.owner());
};
Then build the production boundary deliberately.
[ ] name one intention with one stable key
[ ] fingerprint every effect changing field
[ ] commit the charge and result in one transaction
[ ] forward the same key to an external provider
[ ] replay matches and reject mismatches
[ ] define retention and recovery contracts
The repository stops before the shared transaction. Its value is making that boundary visible.
15 / Adversarial proof
Then Break It
Correctness claims become interesting under adversarial orderings. The repository turns each research question into a real test.
same key, 50 simultaneous attempts
same key, first response lost
same key, different amount
different keys, same payload
same key, expired record
crash after ledger write
The first tests defend the useful property. The last test defines its limit. Run them whenever the reservation or storage code changes.
./gradlew test
For a production implementation, add a test that kills the process before commit and verifies that both the charge and result roll back together. For an external provider, assert that every retry forwards the identical key.
16 / The part that generalizes
The General Lesson Is Reconciliation
Idempotency is often introduced as duplicate suppression. That description is too small. The mechanism reconciles two parties after communication becomes ambiguous: the client retains the name of its intention, and the server retains the result associated with that name.
wrong model: a retry is another request to perform work
right model: a retry asks for the result of named work
The same idea appears anywhere delivery can repeat while effects must not: provisioning, order creation, job submission, and message handling. The transport may deliver again. The business operation still needs a stable identity and an atomic place where history becomes true.
The safest retry is not the one that guesses whether the first call failed. It is the one that never needs to guess.