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
Section titled “Import”import {idempotency} from '@centralping/ergo';Options
Section titled “Options”| 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 |
Return Value
Section titled “Return Value”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).
Error Responses
Section titled “Error Responses”| 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}}, }),);router.post('/payments', { idempotency: {required: true}, validate: { body: paymentSchema, }, execute: async (req, res, acc) => { const result = await processPayment(acc.body.parsed); return {response: {statusCode: 201, body: result}}; },});ergo-router automatically includes body parsing for POST, PUT, and
PATCH routes and places idempotency after it in the pipeline
(Stage 3: body → idempotency → validate). No manual ordering is
needed — the pipeline builder handles placement. See
Config Resolution for
how defaults, per-route overrides, and false (disable) interact.
Header Format
Section titled “Header Format”The Idempotency-Key header value must be an
RFC 8941 structured field string
— a double-quoted value on the wire:
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.
Storing Responses for Replay
Section titled “Storing Responses for Replay”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; } },);router.post('/payments', { idempotency: {required: true}, validate: { body: paymentSchema, }, execute: 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; } },});Lifecycle
Section titled “Lifecycle”- New request — the middleware returns
{ key, fingerprint, complete, discard }on the domain accumulator atacc.idempotency. The execute handler runs normally. complete(response)— stores the response for this key and returnstrue. Returnsfalseif the entry was evicted before completion (the response is not stored). The argument should be a response accumulator shape (e.g.{statusCode, body}).- 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.
discard()— removes the key entry, allowing the next request with that key to be treated as new.
RFC References
Section titled “RFC References”Related Recipes
Section titled “Related Recipes”- Secure Mutations — Integrated CSRF + auth + idempotency workflow with client-side fetch wiring
API Reference
Section titled “API Reference”See the auto-generated idempotency API docs.