Designing robust and predictable APIs with idempotency
A network call that times out leaves the client unable to tell whether the operation happened. For a payments API, both guesses are catastrophic. Retry a charge that succeeded and you double-charge the customer; fail to retry one that failed and they never get what they paid for. The post lays out how Stripe makes its APIs safe to retry, in three layers. Where HTTP semantics allow it, endpoints are made idempotent - built so that repeating a request produces the same result as sending it once. Where an operation must happen exactly once, the client sends an idempotency key so the server can recognize a retry. And clients retry with discipline - exponential backoff plus jitter - so that a fleet of recovering clients never becomes the next outage.
Crash the same charge three ways — with and without an idempotency key.
Problem
Networks fail at a constant background rate. Any call between two machines can fail in three distinct ways. First, the connection fails before it ever reaches the server. Second, the call reaches the server but fails partway through, while the server is doing the work. Third, the work succeeds but the connection breaks before the client hears the result. The first case is easy: nothing happened, so the client can safely retry. The other two are the hard ones. In both, the operation may or may not have taken effect, and from where the client sits those two outcomes look identical - so it has no safe way to know whether retrying will repeat the work or not.
For most operations this ambiguity is a mild annoyance. For a payments API it is the worst case. In an ambiguous failure, the client has to guess. If it retries and the charge had in fact already succeeded, it double-charges the customer. If it decides not to retry and the charge had in fact not gone through, it drops real revenue and leaves the customer without what they paid for. Without some scheme agreed between client and server ahead of time, every transient network hiccup forces a choice between those two bad outcomes. And there is no way to choose correctly, because the very information needed to choose is what got lost.
The post is careful to frame how low the bar is for this problem to apply. A distributed system here means as little as two computers passing messages over a network - the Stripe API plus any one server calling it already qualifies. There is no scale threshold below which the ambiguous-failure problem goes away; it is a property of the network itself, present from the very first remote call, not something that appears only once traffic grows large.
Solution
The first layer is making endpoints idempotent wherever the operation's meaning allows it - idempotent meaning the endpoint can be called any number of times while the underlying change happens only once. HTTP already builds this in. Under the standard that defines HTTP methods (RFC 7231), PUT and DELETE are defined as idempotent. A PUT that fully specifies the target resource (the post's example is a DNS provider creating a CNAME record) can simply be retried until it verifiably succeeds. The server treats a duplicate as a no-op - it sees the resource already exists and just returns success. For this class of operation, a retry loop is the whole answer; no extra machinery is needed.
For operations that must happen exactly once - the classic example is charging a customer - idempotency keys carry the guarantee instead. The client generates a unique ID for the logical operation and sends it with the request; the server ties that key to the state of the request on its side. Stripe implements this on all mutating (POST) endpoints through the Idempotency-Key header: send the charge again with the same key, and the customer is charged only once.
The key resolves each of the three failure cases differently. On a connection failure, the retry is the first time the server has seen the key, so it just processes it normally. On a mid-operation failure, the server picks up the interrupted work and carries it through. The post is explicit that the mechanics here depend heavily on implementation. If the interrupted attempt was cleanly rolled back by an ACID database (one that guarantees a partial operation leaves no trace), the retry can safely re-run the whole thing. Otherwise, the server has to recover the partial state and continue from where it stopped. On a response failure - the work finished but the client never heard - the server simply replays the saved result of the completed operation.
The final layer is what the post calls being a good distributed citizen. A failure might be a brief blip, or it might be a server in the middle of an outage - and in the second case, persistent retries pile load onto a server exactly when it can least afford it. So clients should back off exponentially, waiting proportionally to 2^n as failures repeat, and add random jitter to each wait. The reason jitter matters: if one incident knocks out many clients at the same instant, plain backoff leaves them all on the same schedule, so their retries land in synchronized waves that hammer the recovering server - the thundering herd. Randomizing each client's waits spreads those retries out. Stripe's own Ruby client library ships the whole combination by default: automatic retries with an idempotency key, increasing backoff, and jitter.
Tradeoffs
- Idempotency keys move real work onto the server's bookkeeping: it must durably store key-to-state records and consult them on every request, and that storage must be retained long enough to outlive any realistic retry window. The post's response-failure case - replaying the cached result - only works if the result is still there when the retry arrives, so retention policy becomes part of the API's correctness contract, not an operational afterthought.
- The burden of key discipline lands on the client. The client generates the key, decides its scope, and must never reuse a key across logically distinct operations - a client that recycles an old key for a new charge gets the old charge's cached outcome back, silently. The server cannot detect this; correctness now depends on key-generation hygiene in every integrating codebase.
- The hardest failure case - a crash midway through the operation - is named but not solved by the key alone. The post says it directly: recovery behavior is heavily dependent on implementation. An ACID rollback makes wholesale retry safe, but any operation whose side effects span systems that don't share a transaction needs explicit state recovery before the call can continue. The key tells the server that this is a retry; what the server then has to do to recover safely is where the real work lives.
- Being a good citizen costs each client something. Exponential backoff means a client's state can stay divergent from the server's for progressively longer windows - by the fifth failure it's waiting tens of seconds - and jitter deliberately adds more delay variance on top. Each client agrees to take longer to get back in sync with the server, so that all the clients together don't overwhelm the server and stop it from recovering at all.
- The two idempotency regimes don't unify. Naturally idempotent endpoints (PUT/DELETE, fully-specified resources) need no keys; exactly-once operations (POST charges) need the full key machinery. An API that offers both carries two different retry rules at once, and the developers integrating against it have to know which kind each endpoint follows. That is a documentation and design burden, which the post answers by recommending idempotency be applied widely and made explicit.
Patterns in this article
- Idempotency Keys
This is where the pattern gets its clearest early statement: a client-generated unique ID sent with each mutating request (Stripe's Idempotency-Key header on all POST endpoints). That one key lets the server handle all three network-failure cases: process a fresh request, pick up and finish an interrupted one, or replay the saved result of one that already completed. The same key going in always produces the same outcome coming out.
- Retry with Backoff and Jitter
The post's 'good distributed citizen' section is a tight statement of this pattern. Back off exponentially (each wait scaling with 2^n) so repeated failures slow the retry rate. Then add random jitter, so that clients knocked out together don't all retry in sync and re-hammer the server (the thundering herd). Stripe's Ruby library ships the full set - retries, keys, backoff, jitter - as its default behavior.
Also solving this
Other systems in behindscale's Ambiguous failure under retry class: