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.

interface IdempotencyOptions {
cache: Cache;
ttlMs?: number; // Default 24 h
headerName?: string; // Default 'idempotency-key'
keyPrefix?: string; // Default 'idem:'
missingHeader?: 'reject' | 'pass-through'; // Default 'reject'
}
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.

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.

Für Fehler-Responses ist das Verhalten konfigurierbar. Per Default: 4xx + 5xx werden ebenfalls gecacht (damit ein “Payment fehlgeschlagen” beim Retry nicht zu einem “Payment erfolgreich” wird). Das entspricht der Standard-Idempotency-Key- Semantik.

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 } from 'actor-ts';
idempotent({
cache: new RedisCache({ url: 'redis://...' }),
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):

  • Der erste, der den Cache-Lock schnappt, verarbeitet; der zweite sieht “noch in flight” und … je nach Implementierung unterschiedlich.

Die Middleware des Frameworks lockt pro Key: der zweite Request wartet, bis der erste abgeschlossen ist, und liefert dann die gecachte Response. Meist Sub-Sekunden-Wartezeit; gebunden durch die Handler-Laufzeit.