콘텐츠로 이동
한국어

Idempotency-key middleware

이 콘텐츠는 아직 번역되지 않았습니다.

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';
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:'
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.
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.
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 + 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.

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