Part 2 · 2 chapters · ~12 min
Idempotency End to End
Idempotency keys from the client, request fingerprints, storing responses, concurrent duplicates, propagating references to rails and processors, idempotent consumers for events and webhooks, key retention, and testing idempotency.
5
Keys, fingerprints and stored responses
code
CREATE TABLE idempotency_requests (
key text PRIMARY KEY, fingerprint text NOT NULL, status text NOT NULL, -- processing | done
response_code int, response_body jsonb, created_at timestamptz DEFAULT now());
async function handle(key, body) {
const fp = sha256(canonicalJson(body));
const ins = await db.query(`INSERT INTO idempotency_requests (key, fingerprint, status) VALUES ($1,$2,'processing')
ON CONFLICT (key) DO NOTHING RETURNING key`, [key, fp]);
if (!ins.rowCount) {
const prev = await db.one('SELECT * FROM idempotency_requests WHERE key = $1', [key]);
if (prev.fingerprint !== fp) return [422, { error: 'idempotency_key_reused' }];
if (prev.status === 'processing') return [409, { error: 'request_in_progress' }];
return [prev.response_code, prev.response_body]; // the original answer
}
const [code, res] = await createTransfer(body, key); // ledger entry also unique on key
await db.query(`UPDATE idempotency_requests SET status='done', response_code=$2, response_body=$3 WHERE key=$1`, [key, code, res]);
return [code, res];
}IDEMPOTENCY, END TO END
one key from the phone to the rail, so a retry at any hop moves money once
swipe the figure sideways, or tap expand for full screen
1/5
the client key
The app generates the key once per user intent (when the confirm screen opens) and reuses it on every retry, including after app restarts (stored with the draft).
one key per intent, reused on retryTrust course: the client side
6
Idempotent consumers and retention
| where | idempotency mechanism |
|---|---|
| public API | Idempotency-Key header, fingerprint, stored response (24 h to 30 days retention) |
| ledger | unique idempotency_key on journal entries: the last line of defence |
| outbound to rails | a unique reference per attempt-family; status enquiry before retrying |
| inbound webhooks | dedupe on the provider's event id; process inside a transaction that records it |
| event consumers | processed_events table keyed by event id, written in the same transaction as the effect |
Test it: send every money-moving request twice concurrently and sequentially in integration tests, and assert balances changed once (Node course part 12 has the test).