Idempotency-Key-Middleware
idempotent setzt At-most-once-Schreibverarbeitung
über einen vom Client gelieferten Header durch:
POST /api/paymentsIdempotency-Key: tx-1684923847-abc
→ 201 Created (erstes Mal — verarbeitet + gespeichert){ "txId": "tx-42" }
POST /api/paymentsIdempotency-Key: tx-1684923847-abc ← selber Key
→ 201 Created (zweites Mal — aus Cache zurückgegeben, keine erneute Verarbeitung){ "txId": "tx-42" }Der Handler läuft nur beim ersten Request. Spätere Requests mit demselben Key liefern die gecachte 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 Stunden missingHeader: 'reject', // 400, wenn der Header fehlt});
const routes = path('api', path('payments', post(dedup(processPayment))),);Warum das wichtig ist
Abschnitt betitelt „Warum das wichtig ist“Netzwerk-Retries sind häufig. Ohne Idempotenz:
Client → POST /payments (100 €) → Netzwerk-Timeout (Request war serverseitig tatsächlich erfolgreich)Client → POST /payments (100 €) ← Retry; bucht doppelt abMit Idempotency-Key sieht der Retry: “Key bereits verarbeitet, hier ist die ursprüngliche Response.” Keine Doppelbuchung.
Konfiguration
Abschnitt betitelt „Konfiguration“type IdempotencyOptions = { cache: Cache; ttlMs?: number; // Default 24 h headerName?: string; // Default 'idempotency-key' keyPrefix?: string; // Default 'idem:' missingHeader?: 'reject' | 'pass-through'; // Default 'reject' identity?: (req: HttpRequest) => string | Promise<string>; // Scope pro Aufrufer; Default keiner};| Feld | Zweck |
|---|---|
cache | Backing-Store. Redis ist für Multi-Pod nötig. |
ttlMs | Wie lange jeder Key gemerkt wird. Default 24 Stunden. |
headerName | Header-Namen anpassen (case-insensitive). Default 'idempotency-key'. |
keyPrefix | Cache-Key-Namespace. Default 'idem:', damit mehrere Idempotency-Wrapper im selben Redis nicht kollidieren. |
missingHeader | Was tun, wenn der Header fehlt. Default 'reject' (400); auf 'pass-through' setzen, um den Handler ohne Dedup laufen zu lassen, wenn nur manche Clients Idempotency nutzen. |
identity | Optionaler Scope pro Aufrufer, der in den Cache-Key gefaltet wird, damit eine gecachte, identitätsspezifische Response nie an einen anderen Aufrufer ausgeliefert wird — z. B. (req) => req.headers['x-account'] ?? 'anon'. Ohne ihn teilen sich zwei Aufrufer mit gleichem Key + Methode + Pfad + Body eine Response; nur für identitätsunabhängige Endpunkte sicher (security audit HTTP-4). |
Der Wrapper speichert zusätzlich einen SHA-256-Hash des Request-Bodies neben jeder gecachten Response. Wenn ein zweiter Request mit demselben Key, aber anderem Body hereinkommt, lehnt der Wrapper mit 422 ab — das verhindert, dass ein Client (böswillig oder fehlerhaft) denselben Key für einen semantisch anderen Request wiederverwendet und so die falsche gespeicherte Response erhält.
Was gecacht wird
Abschnitt betitelt „Was gecacht wird“{ status: 201, headers: { 'content-type': 'application/json' }, body: '{"txId":"tx-42"}',}Die Middleware speichert die komplette Response. Spätere Requests mit demselben Key bekommen eine identische Response — gleicher Status, gleiche Header, gleicher Body.
Bei Fehler-Responses kommt es darauf an, ob der Handler etwas zurückgibt oder wirft. Eine zurückgegebene Response wird bedingungslos gecacht — eine 4xx oder 5xx wird beim Retry genauso abgespielt wie eine 2xx, damit ein “Payment fehlgeschlagen” nicht zu einem “Payment erfolgreich” wird. Ein Handler, der stattdessen wirft, verwirft seinen In-Flight- Anspruch und lässt den Key frei, sodass der Client erneut versuchen und den Handler nochmals ausführen kann. Es gibt keine Option, um auszuwählen, welche Statuscodes gecacht werden.
Scoping pro Mandant
Abschnitt betitelt „Scoping pro Mandant“Für Key-Isolation pro Mandant baue einen separaten
idempotent-Wrapper pro Mandant (oder nimm den Mandanten in
keyPrefix auf):
const dedupForTenant = (tenant: string) => idempotent({ cache, ttlMs: 24 * 60 * 60_000, keyPrefix: `idem:${tenant}:`, });Mandant As key-123 ist dann verschieden von Mandant Bs key-123.
Wichtig, wenn:
- Verschiedene Mandanten zufällig denselben Key wählen könnten.
- Du pro Mandant abrechnest oder auditest.
Multi-Pod mit Redis
Abschnitt betitelt „Multi-Pod mit Redis“import { RedisCache, RedisCacheOptions } from 'actor-ts';
const redisCacheOptions = RedisCacheOptions.create().withUrl('redis://...');idempotent({ cache: new RedisCache(redisCacheOptions), ttlMs: 24 * 60 * 60_000,});Mit Redis-Backing sieht jeder Pod denselben Idempotenz-State — ein Retry auf pod-2, nachdem das Original pod-1 traf, liefert die gecachte Response.
InMemoryCache → State pro Pod → Retries, die andere Pods treffen, könnten doppelt verarbeiten. In Produktion immer Redis.
Wo einsetzen
Abschnitt betitelt „Wo einsetzen“POST /api/payments ✓ Idempotency-Key empfohlenPOST /api/orders ✓ dasselbePOST /api/emails ✓ Doppelversand vermeidenPUT /api/users/:id ✓ Retries sicherGET /api/users/me ✗ nicht nötig (bereits idempotent)DELETE /api/orders/:id ✓ Retries sicherAuf jeden mutierenden Endpoint, bei dem Doppelverarbeitung schädlich ist, anwenden.
Verantwortung auf Client-Seite
Abschnitt betitelt „Verantwortung auf Client-Seite“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 }),});
// Beim Retry: DENSELBEN KEY WIEDERVERWENDENfetch('/api/payments', { method: 'POST', headers: { 'idempotency-key': key }, // ← selber Key body: JSON.stringify({ amount: 100 }),});Der Client muss den Key generieren + mit demselben Key retrien. Wenn der Client pro Versuch einen frischen Key erzeugt, sieht die Middleware sie als verschiedene Requests und verarbeitet jeden.
Häufiger Bug: den Key innerhalb der Retry-Schleife zu erzeugen statt einmal vor dem ersten Versuch.
In-Flight-Behandlung
Abschnitt betitelt „In-Flight-Behandlung“Wenn zwei Requests mit demselben Key gleichzeitig ankommen
(Doppelklick, paralleler Retry), beansprucht der erste den Key und
führt den Handler aus; der zweite sieht den Key in flight und
wird sofort mit 409 Conflict abgelehnt ({ "error": "idempotency-key in-flight; retry shortly" }) — er wird nicht
eingereiht oder zum Warten gezwungen.
Der Client wiederholt nach einem kurzen Backoff; sobald der erste Request abgeschlossen ist und seine Response gecacht hat, spielt der Retry diese gespeicherte Response ab. Das schnelle Ablehnen (statt die zweite Verbindung über die gesamte Handler-Laufzeit offen zu halten) bedeutet außerdem, dass es keinen Cross-Pod-Lock zu koordinieren gibt — der In-Flight-Marker liegt im selben Cache wie die gecachten Responses.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- HTTP-Übersicht — das große Bild.
- Response-Cache-Middleware — komplementäre Lese-Seite.
- Rate-Limit-Middleware — Request-Limits pro Key.
- Cache-Übersicht — der Backing-Store.
