pattern

Idempotency in Practice

also called Safe Retry, Exactly-Once Effect

The full treatment — why retries make it mandatory, the four implementation patterns, the client-side bug that defeats it, and how Stripe made it a public contract.

idempotencyreliabilitypaymentsapi-design

Definition

An operation is idempotent when performing it many times has the same effect as performing it once. Not the same response necessarily — the same effect on state.

Why it matters

Because a client that sends a request and receives a timeout cannot distinguish "the request never arrived" from "it succeeded and the response was lost". There is no client-side cleverness that resolves this; the information does not exist.

So the client must either retry, risking a duplicate, or not retry, risking a lost operation. At any real volume both happen constantly — network failures during the response are routine, not exceptional. Idempotency is what makes the choice safe.

This is also why "at-least-once delivery" is the honest guarantee of every messaging system, and why idempotent consumers are not an optimisation but the thing that makes at-least-once usable.

Implementation patterns

Natural idempotence. The operation is a set rather than an increment: status = SHIPPED rather than advance_status(). Repeating changes nothing. Cheapest by far, and frequently available if the message carries the new value rather than a delta.

Idempotency key with stored result. The client generates a key per logical operation; the server records the key with the response and returns the stored response on a repeat. The key and the effect must be committed atomically, or a crash between them reintroduces the problem.

Conditional write. The update carries an expected version or state, and the database rejects it if the version has moved — fencing by another name. The rejection happens at the resource rather than in application logic, which is stronger.

Deduplication window. Record processed message identifiers and drop re-arrivals. Requires a retention decision, because storing every identifier forever is not viable and a duplicate arriving after the window will be processed.

Failure scenarios

The client regenerates the key on each retry. This is the most common defect and it silently defeats a perfectly correct server. The key must be generated when the operation is created — when the user commits to paying — and persisted with it, so every attempt including after a restart carries the same value.

Non-atomic recording. The effect commits, the process dies, the key is never recorded. The retry executes again.

Concurrent requests with the same key, where both pass the "have I seen this?" check before either writes. Needs a lock or a unique constraint.

Key reused with a different payload, returning the wrong cached response. Bind the key to a hash of the request.

The effect is outside the transaction. Exactly-once semantics in a stream processor guarantee effect within the framework's transactional boundary. An email, an HTTP call or a payment is outside it and will happen twice.

Industry example

Stripe exposes an Idempotency-Key header on mutating requests, stores the result against it, and returns the original response on a repeat rather than merely rejecting the duplicate — so the client learns the outcome. Keys expire after a documented window. It is versioned, documented and guaranteed, which is what lets integrators build retry logic against it with confidence.

Trade-offs

Storage that grows with request volume and needs pruning. Additional latency for the key lookup on the hot path. A retention window that must be sized against the longest plausible retry, and stated explicitly rather than left as an implementation detail.

Against that: an entire class of duplicate-charge, duplicate-order and duplicate-message incidents becomes a non-event.

Interview question

A payments API supports idempotency keys. Duplicate charges still occur occasionally. The server implementation is verified correct. Where is the bug?

Look for the candidate to reach the client: the key is being generated inside the retry loop, or at HTTP request construction, rather than once per logical operation. A strong answer also proposes deriving the key deterministically from a stable business identifier, so it survives the client losing local state.