Machine 02 / 03Go · Postgres · payments API2023

An idempotent ledger

A timeout doesn't tell you whether the request failed. It only tells you that you stopped waiting. This machine exists because of that difference.

Role
Design + build, 2 engineers
Traffic
~400 charges / min peak
Status
In production, rewritten once
{{ t.k }}
clientapipostgres
{{ m.t }}
idempotency_keys
{{ k.key }}{{ k.scope }}{{ k.status }}{{ k.age }}
table doesn't exist yet
{{ statLabel }} {{ stat }}
01 · Problem

A phone on a train sends a charge. The server processes it and replies, but the reply never arrives. The app waits, times out, and does the reasonable thing: it tries again. Now the customer has paid twice.

02 · Constraints

Newer apps already sent a request id header. Older ones didn't, and we couldn't force anyone to update. The fix had to live on the server, inside the database we already trusted, without a new service in front of it.

03 · Architecture

The key is written in the same transaction as the charge. Either both exist or neither does. When the charge finishes, the response is saved next to the key, so a retry can get exactly the same answer.

04 · Decision

On a retry, the API finds the key already done and returns the stored response. No new charge. The client can't tell it apart from the original reply, and that's what makes retrying safe.

05 · Trade-off

Keys can't live forever, or the table grows without end. We kept them for 24 hours, because no retry could possibly take longer than that. That sentence turned out to be the bug.

06 · Implementation

Two copies of the same request can arrive at the same moment. INSERT … ON CONFLICT DO NOTHING lets exactly one of them claim the key. The other gets a 409 saying the charge is in progress and to ask again shortly.

07 · What broke

A phone went offline for a weekend with a charge still queued. It came back 26 hours later and retried. The key had expired two hours earlier, so the server saw a brand-new request and charged again.

08 · What I learned

The hard part was never the unique constraint. It was deciding what "the same request" means and for how long. Keys are now scoped per account and kept for seven days. A key that comes back with a different body is rejected rather than trusted.

If I built it again, I'd start with the definition, not the table.

← A rate limiter that forgot All machines Next: A queue, to understand queues · in progress