Failure Modes Retried Request → Idempotency Key

Two requests.
One charge.

A payment request can be sent twice: timeouts, double clicks, client retries. Retry one: the naive API charges the card again, while the idempotency key replays the first response.

charged $0 · 0 sent · 0 retried

Naive, charges every request

customer charged
$0
overcharged
$0
checkout
sends
payment api
charges
postgres
stores
payments

Idempotency key, one charge per key

customer charged
$0
replayed
$0
checkout
sends
payment api
checks key
postgres
stores
idempotency_keys
payments
replayed
no retries yet

Send a payment, then retry it: networks time out, users double-click, clients resend.

How idempotency keys actually work read more

When a request times out, the client cannot know if the charge happened: maybe only the response was lost. Retrying is the only safe recovery, but the naive API treats the retry as a new payment and the customer is charged twice.

The fix: the client generates a key per logical payment and sends it with every attempt. The API inserts the key, with the response it is about to return, in the same transaction as the charge:

BEGIN;
  INSERT INTO idempotency_keys (key, response) VALUES ($1, $2);
  -- unique(key): a retry aborts here and replays the stored response
  INSERT INTO payments (..., status='captured');
COMMIT;

A retry hits the constraint, aborts, and returns the stored response: the client cannot tell a replay from the original. The constraint matters: checking for the key first is a race where two concurrent retries both find nothing and both charge.

The cost: the client must generate and persist the key across its own crashes, responses must be stored, and keys need a scope and an expiry: judgment calls, not defaults.