Charged twice
Networks drop responses and clients retry. Without idempotency keys some customers pay twice — make the network worse and count them.
What you’re seeing
Each payment is one customer pressing “Pay” once. The client sends the request, waits up to a timeout and, hearing nothing, sends it again. A lost reply looks exactly like a lost request — the client cannot know whether the card was charged — so it retries either way.
Without idempotency keys the server cannot tell that retry from a new payment, and charges again. With 15% of replies lost, about 15% of payments end up charged twice; a test checks that, with nothing else going wrong, the two rates match. With keys on it is zero at any loss rate, though each lost reply still costs the customer a full timeout: p99 latency rises from about 0.55 s on a clean network to over 2 s.
How idempotency keys work
The client generates one key per logical operation, typically a random UUID, and sends it with every retry; Stripe’s API, for one, takes it in an Idempotency-Key header. The server stores the key with a fingerprint of the request and its response, in the same transaction as the charge. A repeat of a completed key gets the stored response and charges nothing; a repeat while the first is still in flight gets 409 Conflict and retries later; the same key with a different body gets 422.
Keys expire after a retention window, and the window has to outlast the client’s retries: a retry that arrives once its key is forgotten is a new payment, and is charged. Stripe keeps a key for at least a day. Here a payment sends its last retry within 74 seconds of its first attempt at the most, but a server that cannot keep up may get to it many minutes later: with keys kept for only a minute, that server charged twice with idempotency on.
Keys make a retry safe, but it still lands on the server. In the storm preset, fixed 100 ms retries push the busiest second after the outage to about 31 requests, five times the arrival rate; exponential backoff with full jitter, from AWS’s “Exponential Backoff and Jitter”, holds it to about 25 (means of 40 seeded runs). The backoff does that work: without jitter the peak is about 24, as jitter halves the average wait. Jitter earns its place elsewhere — clients that failed together, like everyone the crash reset, stop retrying together: in a test it spreads their fifth attempts four times wider.
Where it meets my work
At Worldline I handled the edge cases of the critical transaction path in 3-D Secure, integrated with acquirers and issuers. A request that arrives twice is the classic one: repeating it is safe only if the server can recognise it, which is what an idempotency key is for.
The same holds for events: at-least-once delivery, which the outbox experiment gives, is only safe when the receiving side is idempotent.
One server with an unbounded FIFO queue: no replicas or partitions, and none of the other defences — load shedding, deadlines, circuit breakers, retry budgets. That is why, without keys, 5 of 40 seeded storm runs were still backed up 20 s after the outage; with keys a duplicate costs a 5 ms lookup instead of a charge (100 ms plus a 50 ms average tail), and none were. Latencies are a fixed base plus an exponential tail, and messages are lost independently. Charge and key commit in one atomic step, as one database transaction would. Keys are kept for a day, and no more than 50,000 of them, the oldest going first. Seeded: the same settings and clicks replay the same run.
The code behind it
The files that do the work, by their place in this site’s repository.
- web/src/lab/demos/idempotency/
engine.tsThe payment API, its network and its clients, as a seeded discrete-event simulation