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/cache';
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:' maxKeyLength?: number; // Default 255 maxScopeLength?: number; // Default 255 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. Gib ihm eine eigene Instanz — siehe die Warnung unten. |
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. |
maxKeyLength | Längster akzeptierter Header-Wert. Default 255 (Stripes Schranke); ein längerer Key wird mit 400 abgelehnt. |
maxScopeLength | Längstes akzeptiertes identity-Ergebnis. Default 255; ein längerer Scope wird mit 400 abgelehnt. |
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 + Query + Body eine Response; nur für identitätsunabhängige Endpunkte sicher (security audit HTTP-4). Gib eine ID zurück, keinen Freitext — das Ergebnis unterliegt denselben zwei Regeln wie der Header-Wert. |
Der Wrapper speichert zusätzlich einen SHA-256-Fingerprint des
Requests — Methode, Pfad, Query-String und Body — neben jeder
gecachten Response. Wenn ein zweiter Request mit demselben Key
hereinkommt, sich aber in einem dieser vier Teile unterscheidet,
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. POST /refunds?amount=1 und
POST /refunds?amount=9999 sind verschiedene Requests, auch wenn
ihre Bodies identisch sind.
Die Query geht kanonisch ein: Parameter-Keys werden
sortiert, damit ein Retry, der ?a=1&b=2 zu ?b=2&a=1
umsortiert, weiterhin abgespielt statt abgelehnt wird —
Umsortieren ist kein anderer Request. Die Werte eines
wiederholten Keys behalten ihre ursprüngliche Reihenfolge,
sodass ?tag=a&tag=b von ?tag=b&tag=a verschieden bleibt; das
zählt für jede API, die eine solche Liste positionell liest.
Der Key selbst wird validiert
Abschnitt betitelt „Der Key selbst wird validiert“Der Header-Wert ist angreiferseitig gewählt und landet unverändert in einem Cache-Key — er wird deshalb geprüft, bevor er dort ankommt. Ein Request wird mit 400 abgelehnt, wenn der Key
- länger ist als
maxKeyLength(Default 255 Zeichen) oder - ein ASCII-Steuerzeichen oder ein Leerzeichen enthält — CR und LF
sind das klassische Header-Injection-Paar, und Leerzeichen plus der
Steuerzeichenbereich sind Kommando-Trenner in Memcacheds
Text-Protokoll; ein Key mit einem davon wäre also nur sicher, solange
ein bestimmter
Cachehinter der Middleware hängt.
Die Ablehnung nennt die Schranke und den betroffenen Index, nie den Key selbst — Angreifer-Bytes in einen Response-Body zurückzuspiegeln ist genau der Weg, auf dem eine Fehlermeldung zur Payload wird.
Echte Clients senden eine UUID oder ein kurzes opakes Token, keine der
beiden Regeln kostet ehrlichen Traffic also etwas. Erhöhe
maxKeyLength nur für eine Client-Flotte, die du kontrollierst und die
tatsächlich längere Keys erzeugt.
Beide Regeln gelten auch für den identity-Scope, begrenzt durch
maxScopeLength (ebenfalls Default 255), denn der Scope ist die andere
Hälfte desselben zusammengesetzten Keys:
idem:<scope>:<Idempotency-Key>Das zählt vor allem für das Rezept aus der identity-Zeile oben — einen
rohen Client-Header, bei dem der Client die Größe wählt. Ein
zweizeichiger Idempotency-Key mit einem 64-KiB-x-account wurde
früher mit 200 akzeptiert und speicherte einen 64 KiB großen Cache-Key,
unter einer Middleware mit dokumentierter Schranke 255. Leitest du den
Scope stattdessen aus einer validierten Session oder einem Token ab,
bleibt die Prüfung wirkungslos.
Die beiden Schranken sind getrennte Zahlen statt einer Schranke über den
zusammengesetzten Key, damit ein Mandant mit langer ID nicht das Budget
eines ehrlichen Clients verbraucht: 255 + 255 wird akzeptiert, und
maxKeyLength bleibt exakt Stripes veröffentlichte Zahl.
Beachte, was all das begrenzt: wie viel Cache ein erzeugter Key kostet — nicht, wie viele ein Aufrufer erzeugen kann. Die Eviction-Warnung unten ist die andere Hälfte.
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/cache';
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.
