Zum Inhalt springen
Deutsch

Idempotency-Key-Middleware

idempotent setzt At-most-once-Schreibverarbeitung über einen vom Client gelieferten Header durch:

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

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 ab

Mit Idempotency-Key sieht der Retry: “Key bereits verarbeitet, hier ist die ursprüngliche Response.” Keine Doppelbuchung.

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
};
FeldZweck
cacheBacking-Store. Redis ist für Multi-Pod nötig. Gib ihm eine eigene Instanz — siehe die Warnung unten.
ttlMsWie lange jeder Key gemerkt wird. Default 24 Stunden.
headerNameHeader-Namen anpassen (case-insensitive). Default 'idempotency-key'.
keyPrefixCache-Key-Namespace. Default 'idem:', damit mehrere Idempotency-Wrapper im selben Redis nicht kollidieren.
maxKeyLengthLängster akzeptierter Header-Wert. Default 255 (Stripes Schranke); ein längerer Key wird mit 400 abgelehnt.
maxScopeLengthLängstes akzeptiertes identity-Ergebnis. Default 255; ein längerer Scope wird mit 400 abgelehnt.
missingHeaderWas 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.
identityOptionaler 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 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 Cache hinter 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.

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

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

POST /api/payments ✓ Idempotency-Key empfohlen
POST /api/orders ✓ dasselbe
POST /api/emails ✓ Doppelversand vermeiden
PUT /api/users/:id ✓ Retries sicher
GET /api/users/me ✗ nicht nötig (bereits idempotent)
DELETE /api/orders/:id ✓ Retries sicher

Auf jeden mutierenden Endpoint, bei dem Doppelverarbeitung schädlich ist, anwenden.

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

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.