Skip to content

Make every write safe to retry

Give every write an idempotency key so a retry has the same effect as one call.

EngineersSystem designerspostgresawsnodepythongo

Retries are not an edge case any more — they are the normal operating mode of every client, queue, webhook and agent loop you depend on. If a write is not idempotent, then somewhere in your system a timeout that was actually a success is charging a card twice or sending a second email. The client cannot distinguish 'it failed' from 'the response was lost', so the server has to.

What goes wrong: A payment captured twice because the first response timed out on a request that had already succeeded.

What agents change: An agent loop retries on its own initiative and does not know which of its calls already landed.

You are violating it when

  • A retried request creates a second row.
  • Your webhook handler has no dedupe on the event id.
  • Nothing in your write path mentions an idempotency key.

The usual objection: That this is a payments concern. Anything a queue can redeliver or an agent can re-run has the same shape.

A write request needs a caller-provided identity that means “this exact operation,” not merely “this HTTP request.” The server records that identity with the result of the first completed attempt. When the same identity arrives again, the server returns the recorded result instead of performing the side effect again.

This protects the gap between doing the work and the caller learning that it was done. Timeouts, dropped connections, queue redelivery, webhook retries, and process crashes all create cases where the caller cannot know whether the write landed. The idempotency key moves that uncertainty to the server, where the system can check prior work and make the retry converge on one effect.

The misconception is that this is a payments concern. Payments make the damage obvious, but the shape is the same for creating accounts, sending notifications, provisioning resources, applying credits, opening support tickets, or mutating any external system. If the operation can be retried by infrastructure, it can be duplicated unless the write has a stable identity.

When an agent is making calls, retries are less visible and more frequent. The agent may re-plan, re-run a tool, or recover from a partial transcript without knowing which side effects already happened. Idempotency keys give those tool calls a durable memory boundary, so repeated attempts do not expand the blast radius.

Install it

npx klay practices add idempotent-writes
  • .klay/practices/idempotent-writes.mdcreate
    # Idempotent writes
    
    The client cannot tell "it failed" from "the response was lost". So the server
    has to.
    
    Retries are not an edge case any more. Every HTTP client, queue, webhook
    deliverer and agent loop in your stack retries by default. If a write is not
    idempotent then somewhere in your system a timeout that was actually a success
    is charging a card twice.
    
    ## The mechanism
    
    The caller generates a key that is stable across its own retries — a UUID per
    logical operation, not per attempt — and sends it with the request:

The previews are the first lines of each file; the command writes them in full. Existing files are never overwritten.

How you know it stuck

npx klay practices audit reports this check for this practice:

  • idempotency-keys

Where this comes from

  1. Making retries safe with idempotent APIsAmazon Builders' Library · Official docsBroadens the pattern to distributed systems using caller-supplied tokens beyond payment APIs.
  2. Idempotent requests | Stripe API ReferenceStripe Docs · Official docsShows the concrete server behavior: save the first result and replay it for the same key.
  3. RFC 9110: HTTP SemanticsIETF RFCs · Official docsProvides the protocol-level basis for why automatic retries require idempotent semantics.
  4. 10 Tips for Building Resilient Payment Systems - ShopifyShopify Engineering · Engineering blogGrounds the risk in a production double-charge scenario caused by a lost response.

Questions

Can I not just make the operation naturally idempotent?
Better, when you can — a PUT of a full resource beats a key. The key exists for the operations that genuinely create something new each time.