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/cache';
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:' maxKeyLength?: number; // default 255 maxScopeLength?: number; // default 255 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. Give it its own instance — see the caution below. |
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. |
maxKeyLength | Longest accepted header value. Default 255 (Stripe’s cap); a longer key is refused with 400. |
maxScopeLength | Longest accepted identity result. Default 255; a longer scope is refused with 400. |
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 + query + body share one cached response; safe only for identity-agnostic endpoints (security audit HTTP-4). Return an id, not free text — the result is held to the same two rules as the header value. |
The wrapper also stores a SHA-256 fingerprint of the request
— method, path, query string and body — alongside each cached
response. When a second request arrives with the same key but
any of those four differing, 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. POST /refunds?amount=1 and POST /refunds?amount=9999
are different requests even when their bodies are identical.
The query is folded in canonically: parameter keys are
sorted, so a retry that reorders ?a=1&b=2 into ?b=2&a=1
still replays instead of being rejected — reordering is not a
different request. The values of a repeated key keep their
original order, so ?tag=a&tag=b stays distinct from
?tag=b&tag=a, which matters for any API that reads such a
list positionally.
The key itself is validated
Section titled “The key itself is validated”The header value is attacker-chosen and lands verbatim in a cache key, so it is checked before it gets there. A request is refused with 400 when the key
- is longer than
maxKeyLength(default 255 characters), or - contains an ASCII control character or a space — CR and LF are
the classic header-injection pair, and space plus the control range
are command delimiters in Memcached’s text protocol, so a key
carrying one is only safe depending on which
Cachehappens to be wired in behind the middleware.
The rejection names the limit and the offending index, never the key itself — echoing attacker bytes back into a response body is how a rejection message becomes a payload.
Real clients send a UUID or a short opaque token, so neither rule costs
honest traffic anything. Raise maxKeyLength only for a client fleet
you control that genuinely mints longer keys.
Both rules apply to the identity scope too, bounded by
maxScopeLength (also 255 by default), because the scope is the other
half of the same composed key:
idem:<scope>:<Idempotency-Key>That matters most for the recipe the identity row above shows — a raw
client header, where the client picks the size. A two-character
Idempotency-Key with a 64 KiB x-account used to be accepted with 200
and stored a 64 KiB cache key under a middleware whose documented cap is
255. Deriving the scope from a validated session or token instead keeps
the check inert.
The two caps are separate numbers rather than one cap over the composed
key, so a tenant with a long id cannot spend an honest client’s header
budget: 255 + 255 is accepted, and maxKeyLength stays exactly Stripe’s
published figure.
Note what all of this bounds: how much cache one minted key costs, not how many a caller can mint. The eviction caution below is the other half.
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/cache';
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.
