Idempotency-key middleware
idempotent enforces at-most-once write processing via a
client-provided header:
POST /api/paymentsIdempotency-Key: tx-1684923847-abc
→ 201 Created (first time — processed + stored){ "txId": "tx-42" }
POST /api/paymentsIdempotency-Key: tx-1684923847-abc ← same key
→ 201 Created (second time — returned from cache, no re-processing){ "txId": "tx-42" }The handler runs only on the first request. Subsequent requests with the same key return the cached response.
import { idempotent, path, post } from 'actor-ts/http';import { InMemoryCache } from 'actor-ts';
const dedup = idempotent({ cache: new InMemoryCache(), ttlMs: 24 * 60 * 60_000, // 24 hours missingHeader: 'reject', // 400 when the header is absent});
const routes = path('api', path('payments', post(dedup(processPayment))),);Why this matters
Section titled “Why this matters”Network retries are common. Without idempotency:
Client → POST /payments ($100) → network timeout (request actually succeeded server-side)Client → POST /payments ($100) ← retry; charges twiceWith idempotency-key, the retry sees “key already processed, here’s the original response.” No double charge.
Configuration
Section titled “Configuration”type IdempotencyOptions = { cache: Cache; ttlMs?: number; // default 24h headerName?: string; // default 'idempotency-key' keyPrefix?: string; // default 'idem:' missingHeader?: 'reject' | 'pass-through'; // default 'reject' identity?: (req: HttpRequest) => string | Promise<string>; // per-caller scope; default none};| Field | Purpose |
|---|---|
cache | Backing store. Redis is required for multi-pod. |
ttlMs | How long to remember each key. Default 24 hours. |
headerName | Customize the header name (case-insensitive match). Default 'idempotency-key'. |
keyPrefix | Cache-key namespace. Default 'idem:' so multiple idempotency wrappers in the same Redis don’t collide. |
missingHeader | What to do when the header is absent. Default 'reject' (400); set 'pass-through' to run the handler without dedup when only some clients use idempotency. |
identity | Optional per-caller scope folded into the cache key so a cached, identity-specific response is never replayed to a different caller — e.g. (req) => req.headers['x-account'] ?? 'anon'. Without it, two callers reusing the same key for the same method + path + body share one cached response; safe only for identity-agnostic endpoints (security audit HTTP-4). |
The wrapper also stores a SHA-256 hash of the request body alongside each cached response. When a second request arrives with the same key but a different body, the wrapper rejects with 422 — preventing a client (malicious or buggy) from reusing a key for a semantically different request to receive the wrong stored response.
What gets cached
Section titled “What gets cached”{ status: 201, headers: { 'content-type': 'application/json' }, body: '{"txId":"tx-42"}',}The middleware stores the complete response. Subsequent requests with the same key get an identical response — same status, headers, body.
For error responses, what counts is whether the handler returns or throws. A returned response is cached unconditionally — a 4xx or 5xx replays on retry exactly like a 2xx, so a “payment failed” reply isn’t re-processed into a “payment succeeded”. A handler that throws instead drops its in-flight claim, leaving the key free so the client can retry and re-run the handler. There’s no option to choose which statuses get cached.
Per-tenant scoping
Section titled “Per-tenant scoping”For per-tenant key isolation, build a separate idempotent
wrapper per tenant (or include the tenant in keyPrefix):
const dedupForTenant = (tenant: string) => idempotent({ cache, ttlMs: 24 * 60 * 60_000, keyPrefix: `idem:${tenant}:`, });Important when:
- Different tenants might pick the same key by chance.
- You’re billing or auditing per-tenant.
If you need a single wrapper that derives the tenant from the
request itself, wrap the handler with a thin adapter that
re-keys the cache before calling idempotent’s wrapper.
Multi-pod with Redis
Section titled “Multi-pod with Redis”import { RedisCache, RedisCacheOptions } from 'actor-ts';
const redisCacheOptions = RedisCacheOptions.create().withUrl('redis://...');idempotent({ cache: new RedisCache(redisCacheOptions), ttlMs: 24 * 60 * 60_000,});With Redis backing, every pod sees the same idempotency state — a retry to pod-2 after the original hit pod-1 returns the cached response.
InMemoryCache → per-pod state → retries hitting different pods could double-process. Always Redis for prod.
Where to use
Section titled “Where to use”POST /api/payments ✓ idempotency-key recommendedPOST /api/orders ✓ samePOST /api/emails ✓ avoid double-sendsPUT /api/users/:id ✓ retries safeGET /api/users/me ✗ no need (idempotent already)DELETE /api/orders/:id ✓ retries safeApply to any mutating endpoint where double-processing is harmful.
Client-side responsibility
Section titled “Client-side responsibility”const key = `${userId}-${operation}-${Date.now()}-${random}`;
fetch('/api/payments', { method: 'POST', headers: { 'idempotency-key': key, 'content-type': 'application/json', }, body: JSON.stringify({ amount: 100 }),});
// On retry: REUSE THE SAME KEYfetch('/api/payments', { method: 'POST', headers: { 'idempotency-key': key }, // ← same key body: JSON.stringify({ amount: 100 }),});The client must generate the key + retry with the same key. If the client generates a fresh key per try, the middleware sees them as different requests and processes each.
Common bug: generating a key inside the retry loop instead of once before the first attempt.
In-flight handling
Section titled “In-flight handling”If two requests with the same key arrive simultaneously
(double-click, concurrent retry), the first claims the key and runs
the handler; the second sees the key in flight and is rejected
immediately with 409 Conflict ({ "error": "idempotency-key in-flight; retry shortly" }) — it is not queued or made to wait.
The client retries after a short backoff; once the first request has completed and cached its response, the retry replays that stored response. Failing fast (rather than holding the second connection open for the handler’s whole runtime) also means there is no cross-pod lock to coordinate — the in-flight marker lives in the same cache as the cached responses.
Where to next
Section titled “Where to next”- HTTP overview — the bigger picture.
- Response cache middleware — complementary read-side.
- Rate limit middleware — per-key request limits.
- Cache overview — the backing store.
