Pular para o conteúdo
Português (BR)

Idempotency-key middleware

Este conteúdo não está disponível em sua língua ainda.

idempotent enforces at-most-once write processing via a client-provided header:

POST /api/payments
Idempotency-Key: tx-1684923847-abc
→ 201 Created (first time — processed + stored)
{ "txId": "tx-42" }
POST /api/payments
Idempotency-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))),
);

Network retries are common. Without idempotency:

Client → POST /payments ($100)
→ network timeout (request actually succeeded server-side)
Client → POST /payments ($100) ← retry; charges twice

With idempotency-key, the retry sees “key already processed, here’s the original response.” No double charge.

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
};
FieldPurpose
cacheBacking store. Redis is required for multi-pod. Give it its own instance — see the caution below.
ttlMsHow long to remember each key. Default 24 hours.
headerNameCustomize the header name (case-insensitive match). Default 'idempotency-key'.
keyPrefixCache-key namespace. Default 'idem:' so multiple idempotency wrappers in the same Redis don’t collide.
maxKeyLengthLongest accepted header value. Default 255 (Stripe’s cap); a longer key is refused with 400.
maxScopeLengthLongest accepted identity result. Default 255; a longer scope is refused with 400.
missingHeaderWhat 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.
identityOptional 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 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 Cache happens 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.

{
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.

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.

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.

POST /api/payments ✓ idempotency-key recommended
POST /api/orders ✓ same
POST /api/emails ✓ avoid double-sends
PUT /api/users/:id ✓ retries safe
GET /api/users/me ✗ no need (idempotent already)
DELETE /api/orders/:id ✓ retries safe

Apply to any mutating endpoint where double-processing is harmful.

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 KEY
fetch('/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.

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.