A retry is the most common way one service talks to another. Networks drop, timeouts fire, a pod restarts mid-request. If the second attempt cannot be made safely, every one of those ordinary events becomes a data problem.
So I treat idempotency as part of the interface, written down and tested, rather than a property a handler happens to have this week.
The promise, stated plainly
For a given idempotency key, the operation SHALL produce the same observable outcome no matter how many times it is called, and SHALL return the same response body on every call after the first.
Two clauses. Both matter. The first protects the data. The second protects the caller, who otherwise cannot tell a duplicate from a conflict.
The table
The simplest implementation I trust is one table with a unique constraint.
create table idempotency_keys ( key text primary key, request_hash text not null, response jsonb, status text not null check (status in ('in_progress', 'done')), created_at timestamptz not null default now());The handler follows one path:
async function handle(key: string, body: Request): Promise<Response> { const hash = sha256(canonical(body)); const claimed = await claim(key, hash); // insert ... on conflict do nothing if (!claimed) { const existing = await load(key); if (existing.request_hash !== hash) throw conflict('same key, different request'); if (existing.status === 'in_progress') throw retryLater(); return existing.response; } const response = await doTheWork(body); await finish(key, response); return response;}The request_hash check is the part most people skip. Without it, a client that reuses a key for a different payload gets a cached answer to a question it did not ask.1
What I test
- Same key, same body, twice: one side effect, two identical responses.
- Same key, different body: a conflict, no second side effect.
- Same key, concurrent: exactly one claim wins, the other observes
in_progress.
The third test is the one that finds bugs. I run it with a real database, not a mock, because the guarantee comes from the unique constraint and a mock cannot violate one.