sehaj/learn
System design/03 Networking and APIs

03.10 · with code · 10 min

Idempotency keys

Retries are only safe if doing something twice has the same effect as doing it once. Idempotency keys make that true for 'create' and 'charge' calls.

A developer's script calls Snip's API to create a short link for each of 10,000 products. One request reaches Snip, the link is created, and the response is lost to a network blip. The script times out and, following good practice from the last lesson, retries. Snip creates a second link for the same product.

For Snip that's untidy. For a payment API it's a customer charged twice. The client did the right thing; the API made the retry unsafe.

An operation is idempotent if doing it twice has the same effect as doing it once. Reading is idempotent. "Set the link's target to X" is idempotent. "Delete link x7Kp" is idempotent: it's gone either way. "Create a link" and "charge £20" are not: each call does it again. The fix is to give the server a way to recognise the retry.

Idempotency keys

The client generates a unique key for each logical operation (a random UUID is typical) and sends it with the request. Retries of the same operation send the same key:

http
POST /links
Idempotency-Key: 5f1c9a2e-7b41-4d0e-9a8f-0c3e6d2b1a77
{ "long_url": "https://shop.example/products/4471" }

The server remembers each key it has seen, with the response it gave:

A retried create, with an idempotency key
ClientSnip APIDatabase1 POST /links, key K2 insert link + record K3 201 b2Qe4 POST /links, key K (retry)5 look up K6 201 b2Qe (saved)

6 messages. Use Next and Back to follow them in order.

Text version
  1. Client → Snip API: POST /links, key K — The first attempt carries key K.
  2. Snip API → Database: insert link + record K — In one transaction, Snip creates the link and records key K with the response it's about to send: 201 and code b2Qe.
  3. Snip API → Client: 201 b2Qe (reply) (fails) — The response is lost on the way back.
  4. Client → Snip API: POST /links, key K (retry) — The client times out and retries with the same key.
  5. Snip API → Database: look up K — Snip finds key K already recorded, with the same request body.
  6. Snip API → Client: 201 b2Qe (saved) (reply) — It returns the saved response instead of creating another link. The client sees one link, whatever happened in between.

Getting the details right

The idea is simple; the edge cases are where implementations go wrong:

  • Record the key and the effect together. If the link is created but the key isn't recorded (or the reverse), a retry does the wrong thing. Write both in the same database transaction.
  • Same key, different request. If a client reuses a key with a different body, it's a bug on their side. Store a fingerprint of the request with the key, and return an error rather than guessing.
  • Two attempts at once. A fast retry can arrive while the first attempt is still running. The first attempt claims the key (a unique constraint does this), and the second gets a 409 "still in progress" and retries later.
  • How long to remember. Keys only need to outlive the client's retries. A day is common; after that keys can be deleted.
  • Scope keys to the caller. Two different accounts may pick the same key by chance. Store it per account.

A minimal table:

sql
CREATE TABLE idempotency_keys (
  account_id    bigint      NOT NULL,
  key           text        NOT NULL,
  request_hash  bytea       NOT NULL,   -- reject reuse with a different body
  status        int,                    -- null while the first attempt is in progress
  response      jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)
);

Other ways to be idempotent

Keys aren't the only tool. Often the cleanest fix is to change the operation itself:

  • Let the client choose the ID. PUT /links/{id} with a client-generated ID: a retry writes the same row again.
  • Unique constraints. If each product may have one link, a unique index on (account, product_id) turns a duplicate into a clean "already exists" answer.
  • Set, don't add. "Set the count to 18,431" can be repeated; "add 1" can't. Queue consumers (07.03 Delivery guarantees and idempotent consumers) use the same idea, recording which messages or batches they've already applied.

Seen in production

Stripe

2017

Idempotent requests

Stripe's API accepts an Idempotency-Key header on its mutating (POST) endpoints. If an operation succeeded but the client didn't get the result, a retry with the same key gets the saved result instead of a second charge. Stripe's documentation adds that keys can be pruned after they're at least 24 hours old, and that a retry with the same key but different parameters returns an error. Its client libraries retry automatically with backoff, jitter and a key.

What we foundPayments APIs make retries safe with a client-supplied key, and the server replays the saved result.

Designing robust and predictable APIs with idempotencystripe.com ↗

Amazon

2019

Builders' Library

AWS APIs such as EC2's take a caller-provided client request token. Requests from the same caller with the same token are treated as duplicates and get an equivalent response, not an error. The token and the change must be recorded atomically, and the service stores the original parameters so that a reused token with different parameters is rejected with a validation error.

What we foundRecord the caller's token and the change atomically, and treat the same token with different parameters as an error.

Making retries safe with idempotent APIsaws.amazon.com ↗
Predict first

A client sends POST /links with key K. While Snip is still processing it, a retry with key K arrives. What should Snip do?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqMust-knowDesign

How would you prevent a payment from being charged twice when the client retries?

Report a mistakeEdit this page