The idempotency key belongs in the API contract, not in client-side retry logic. Over a network a timeout does not tell you whether the operation happened, so every operation that can be retried must be safe to apply twice.
An operation is idempotent if applying it twice has the same effect as applying it once. HTTP defines this per method (RFC 9110, §9.2.2): PUT and DELETE are idempotent, POST is not. The problem it solves is the indeterminate outcome: the client sent “charge €40”, the connection dropped, and nothing on the client side can tell a lost request from a lost response. Retrying a non-idempotent call risks a double charge; not retrying risks a missing one. Message brokers have the same problem under another name: at-least-once delivery means every consumer sees duplicates, and “exactly-once” in practice means at-least-once delivery plus idempotent processing.
// Idempotency key comes from the client; result is cached atomically with the op
func (s *Svc) CreatePayment(ctx context.Context, key IdempotencyKey, req PaymentReq) (Payment, error) {
return s.store.WithIdempotency(ctx, key, func(tx Tx) (Payment, error) {
return s.doCreate(tx, req) // a retry with the same key returns the stored result
})
}// The type makes the requirement unbreakable: no key, no request
pub struct CreatePayment { pub idempotency_key: Uuid, pub amount: Cents }-- same transaction as the payment itself; a unique key, not a read-then-write check
INSERT INTO idempotency (key, request_hash, response)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO NOTHING;What makes it correct. The key is generated by the client once per logical operation and reused on every retry; Stripe’s Idempotency-Key header is the well-known public example, and an IETF draft (“The Idempotency-Key HTTP Header Field”) standardizes it — still a draft, not an RFC. The server stores the key and the response in the same transaction as the effect, enforced by a unique constraint; a separate “check, then write” races. A reused key with a different request body is an error, not a replay — hence the request hash. Keys expire after a window longer than any client’s retry horizon.
Limits. Idempotency covers the service’s own state. A side effect outside the transaction — an email, a call to a payment provider — needs its own key passed downstream, or an outbox (68). Idempotent is also not commutative: two different updates arriving out of order still need versioning or a fencing token (66).