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

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