idempotency-key
v0.1.0
Published
Run a request handler at most once per Idempotency-Key, across replicas. Reserve-then-run against Redis or Postgres, with response replay and fingerprint checking — not check-then-run, which is the bug it exists to prevent.
Maintainers
Readme
idempotency-key
A retried request must not charge the card twice.
The middleware everyone writes looks like this, and it is wrong:
const cached = await store.get(key);
if (cached) return cached; // <- the check
const result = await handler(); // <- and the gap
await store.set(key, result);Two requests arriving together both see nothing cached, and both run. Measured with three concurrent calls sharing one key:
check-then-run : handler ran 3 times for one key
reserve-then-run : handler ran 1 timeThe fix is to make the reservation itself atomic, in a store all your replicas
share — because an in-process Map closes the gap inside one process and three
pods still charge three times.
npm install idempotency-keyUse
import { Idempotency, RedisStore, fingerprint } from 'idempotency-key';
const idem = new Idempotency({ store: new RedisStore(redis) });
app.post('/charges', async (req, res) => {
const key = req.headers['idempotency-key'];
const result = await idem.run(
key,
{ fingerprint: fingerprint({ method: 'POST', path: req.path, body: req.rawBody }) },
async () => {
const charge = await chargeTheCard(req.body);
return { status: 201, body: JSON.stringify(charge) };
},
);
res.status(result.status);
if (result.replayed) res.set('Idempotent-Replay', 'true');
res.send(result.body);
});Framework-agnostic on purpose: it takes a key and a handler and gives you back a status, headers and a body. Express, Fastify, Hono, a Lambda, a queue consumer — none of them are mentioned anywhere in the package.
Stores
| | reservation | atomic because |
|---|---|---|
| RedisStore | SET key NX EX ttl | the check and the write are one command |
| PostgresStore | INSERT … ON CONFLICT DO NOTHING … RETURNING | one statement returns a row only to the inserter |
| MemoryStore | a Map | it is not, across replicas — named so nobody deploys it by accident |
reserve is the only operation the interface specifies as atomic, and it is the
only one that matters. Everything else is bookkeeping around it. A store that
implements it as a read followed by a write passes every other test in this
repository and loses the race in production, which is why the same suite runs
against all three.
The Postgres store has a second advantage worth naming: the reservation lives in the same database as your business write, so a handler writing in the same transaction cannot leave a key saying "done" for work that rolled back.
The four decisions
Concurrent duplicate → 409, or wait. Default is 409 Conflict, which is
what Stripe does and what a well-behaved client retries. onInFlight: 'wait'
blocks until the winner finishes and replays its response, which is friendlier
and can hold a connection for as long as the handler takes.
Same key, different body → 422. That is a client bug, not a retry, and replaying the first response would answer a question nobody asked. The fingerprint covers method, path and body — deliberately not headers, which carry tracing ids and timestamps that differ between two retries of the same request and would turn every legitimate retry into a 422.
5xx is not remembered; 4xx is. A 422 for a malformed body will be a 422 next
time, so replaying it is correct. A 500 is usually transient, and caching it
would make every retry return the same 500 for the whole TTL — an outage that
outlives its cause, produced by the code meant to make retries safe. Override
with cacheErrors.
A thrown handler releases the key. It produced no response to remember, and holding the reservation would lock the key for 24 hours over a blip.
What it does not do
It does not make your handler idempotent. It runs it at most once per key. If the handler itself writes twice, or writes and then fails before the response is stored, that is still yours to get right — the usual answer is a transactional outbox, and the Postgres store exists partly so both can share one transaction.
It does not generate keys. The client owns the key, because the client is what knows that two requests are the same attempt. A server-generated key is a request id with extra steps.
It does not sweep Postgres for you. PostgresStore.sweep() deletes expired
rows; run it on a timer. Nothing depends on it for correctness — an expired key
is overwritten by the next caller rather than blocking one.
Tests
npm install
npm run infra:up
npm test # 30 tests: the same 10 against all three stores
npm run infra:downThe one to read first is "runs the handler exactly once for concurrent duplicates". A check-then-run implementation passes every other test in the file and fails that one, which is also the only test that resembles what happens in production.
Built with Claude
Claude wrote most of this code. The design is mine, and so is the decision to measure the problem before writing any of it: the three-runs-for-one-key result at the top of this README came from a probe, not from a blog post.
Licence
MIT.
