Skip to content

idempotency

Implements the Idempotency-Key HTTP header draft specification. Replays stored responses for duplicate request keys and detects fingerprint conflicts for concurrent or mismatched requests.

Place after body() in the pipeline (Stage 3) so the request fingerprint can include the parsed body.

Pipeline stage: Validation (after body parsing)

import {idempotency} from '@centralping/ergo';
Option Type Default Description
store object In-memory store Pluggable store — get(key) → object|undefined, set(key, fingerprint) → string, complete(key, response, generation) → boolean, delete(key) → void
ttlMs number 86400000 (24h) TTL for default in-memory store
required boolean false Return 400 if header is missing on applicable methods
methods Set<string> | string[] POST, PATCH HTTP methods to enforce. Must be a non-empty Set or Array of non-empty strings when provided
keyGenerator function identity (parsedKey, req, domainAcc) => string — scopes the store key (e.g. bind to auth principal). Must be a function when provided

Depends on the request state:

State Return
Method not applicable or key absent (not required) undefined (compose-with leaves acc.idempotency unset)
New request {value: {key, fingerprint, complete(response) → boolean, discard()}}
Replay (stored response exists) {value: {replayed: true}, response: storedResponse}

For new requests, call complete(response) after your execute stage to store the response for future replays, or discard() to remove the key. complete() returns true on success, or false if the entry was evicted before completion (the response is not stored).

Status Condition
400 Bad Request Header is present but not a valid RFC 8941 sf-string (always, regardless of required)
400 Bad Request Header is absent and required: true
409 Conflict Same key with a different request fingerprint
409 Conflict Concurrent request with the same key still processing
import {compose, body, idempotency} from '@centralping/ergo';
const pipeline = compose(
body(),
idempotency({required: true}),
(req, res, acc) => ({
response: {statusCode: 201, body: {created: true}},
}),
);

The Idempotency-Key header value must be an RFC 8941 structured field string — a double-quoted value on the wire:

Terminal window
curl -X POST https://api.example.com/payments \
-H 'Idempotency-Key: "my-unique-key-123"' \
-H 'Content-Type: application/json' \
-d '{"amount": 1000}'

Unquoted values (e.g., Idempotency-Key: my-key) are rejected with a 400 response that includes format guidance.

For new requests, the middleware returns complete() and discard() callbacks. Call complete(response) after your execute logic to store the response for future replays, or discard() to remove the idempotency entry (e.g. on failure).

import {compose, body, idempotency} from '@centralping/ergo';
const pipeline = compose(
body(),
idempotency({required: true}),
async (req, res, acc) => {
try {
const result = await processPayment(acc.body.parsed);
const response = {statusCode: 201, body: result};
acc.idempotency.complete(response);
return {response};
} catch (err) {
acc.idempotency.discard();
throw err;
}
},
);
  1. New request — the middleware returns { key, fingerprint, complete, discard } on the domain accumulator at acc.idempotency. The execute handler runs normally.
  2. complete(response) — stores the response for this key and returns true. Returns false if the entry was evicted before completion (the response is not stored). The argument should be a response accumulator shape (e.g. {statusCode, body}).
  3. Replay — a subsequent request with the same key and fingerprint returns the stored response immediately. The execute handler does not run — the middleware handles replay automatically.
  4. discard() — removes the key entry, allowing the next request with that key to be treated as new.
  • Secure Mutations — Integrated CSRF + auth + idempotency workflow with client-side fetch wiring

See the auto-generated idempotency API docs.