Idempotency-Keys in REST-APIs
Ein Client schickt eine Zahlungsanfrage, das Netzwerk läuft in ein Timeout, bevor die Antwort zurückkommt, und der Client wiederholt – vernünftigerweise – die Anfrage. Führt dein POST /payments-Endpunkt die Abbuchung einfach erneut aus, wird der Kunde doppelt belastet. Idempotency-Keys lösen genau dieses Problem: Sie erlauben es einem Client, eine Anfrage sicher zu wiederholen, die möglicherweise erfolgreich war oder nicht, ohne das Risiko eines doppelten Seiteneffekts.
Die Kernidee
Ein Idempotency-Key ist ein eindeutiger Wert, den der Client einmal pro logischer Operation erzeugt (meist eine UUID) und bei jedem Versuch dieser Operation mitsendet, einschließlich Wiederholungen.
POST /payments
Idempotency-Key: 8f14e45f-ceea-4f3f-9d5f-3b6b1a2c9a10
{ "amount": 4999, "currency": "usd", "customerId": "cus_123" }
Die Aufgabe des Servers ist im Prinzip einfach: Sieht er einen bestimmten Key zum ersten Mal, führt er die Operation aus und speichert das Ergebnis unter diesem Wert. Jede folgende Anfrage mit demselben Key liefert das gespeicherte Ergebnis zurück, statt die Operation erneut auszuführen – auch wenn der Request-Body identisch aussieht, auch wenn sie Sekunden oder Stunden später eintrifft.
Eine minimale Implementierung
So sieht es aus, bei einem Node/Express-Handler mit einer Datenbanktabelle dahinter:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response_body JSONB,
response_status INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handlePayment(req, res) {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const requestHash = hashBody(req.body);
const existing = await db.query('SELECT * FROM idempotency_keys WHERE key = $1', [key]);
if (existing.rows.length) {
const record = existing.rows[0];
if (record.request_hash !== requestHash) {
return res.status(422).json({ error: 'Idempotency key reused with a different request body' });
}
if (record.status === 'completed') {
return res.status(record.response_status).json(record.response_body);
}
return res.status(409).json({ error: 'Request with this key is still processing' });
}
await db.query(
'INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, $3)',
[key, requestHash, 'in_progress'],
);
const result = await chargeCustomer(req.body);
await db.query(
'UPDATE idempotency_keys SET status = $1, response_body = $2, response_status = $3 WHERE key = $4',
['completed', result.body, result.status, key],
);
return res.status(result.status).json(result.body);
}
Die request_hash-Prüfung ist wichtig: Sie verhindert, dass ein Client einen Key versehentlich (oder böswillig) für eine tatsächlich andere Operation wiederverwendet. Passt der Body nicht zu dem, was für diesen Key gespeichert wurde, lehne ab, statt die neue Anfrage stillschweigend auszuführen oder die alte Antwort zurückzugeben.
Die Race Condition, die man behandeln muss
Die naive Version oben hat eine Lücke: Zwei Anfragen mit demselben Key können beide die Prüfung „existiert er schon?" bestehen, bevor eine von beiden eine Zeile einfügt, und beide belasten den Kunden. Die Lösung: den Insert selbst zum Punkt der Atomarität machen – eine Unique-Constraint auf key verwenden und die Datenbank den zweiten Insert ablehnen lassen:
try {
await db.query(
'INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, $3)',
[key, requestHash, 'in_progress'],
);
} catch (err) {
if (err.code === '23505') { // unique_violation
// Someone else beat us to it — poll or return 409, don't proceed.
return res.status(409).json({ error: 'Request with this key is already being processed' });
}
throw err;
}
Das ist der ganze Grund, warum Idempotency-Keys überhaupt eine Datenbank (oder ein Äquivalent) brauchen – eine In-Memory-Map auf einer einzelnen Serverinstanz rettet dich nicht, sobald mehr als ein Server hinter einem Load Balancer steht, und sie übersteht definitiv keinen Neustart mitten in einer Anfrage.
Was als „die Operation" zählt
Idempotency-Keys schützen vor Wiederholungen derselben Anfrage, nicht davor, dass der Client den Endpunkt absichtlich zweimal mit unterschiedlichen Keys aufruft. Diese Unterscheidung liegt in der Verantwortung des Clients: einen Key pro logischer Handlung erzeugen (pro Checkout-Versuch, pro Formularübermittlung), nicht einen Key pro HTTP-Aufruf, den der Client gerade macht, und nicht einen festen Key, der über unabhängige Operationen hinweg wiederverwendet wird.
Ein häufiger Fehler ist, bei jeder Wiederholung eine frische UUID zu erzeugen, statt die des ersten Versuchs wiederzuverwenden – das hebelt den gesamten Mechanismus aus, weil der Server jede Wiederholung als brandneuen Key sieht und frisch verarbeitet. Der Key muss einmal vor dem ersten Versuch erzeugt und durch jede Wiederholung desselben Versuchs mitgeführt werden.
Alte Keys ablaufen lassen
Diese Tabelle soll nicht ewig wachsen. Idempotency-Keys sind nur für das Wiederholungsfenster nützlich, das ein Client realistisch nutzt – ein Hintergrundjob oder Cron kann Zeilen, die älter als etwa 24 Stunden sind, gefahrlos löschen:
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
Wiederholt ein Client irgendwie nach diesem Fenster mit demselben Key, ist der schlimmste Fall eine echte doppelte Operation – ein akzeptabler Kompromiss, da eine Wiederholung einen Tag später mit ziemlicher Sicherheit nicht mehr dieselbe Nutzeraktion ist.
Wohin das tatsächlich gehört
Nicht jeder Endpunkt braucht das. GET-Anfragen sind per Definition idempotent – zweimal ausgeführt haben sie keine Seiteneffekte, also gibt es nichts zu schützen. Die Technik ist wichtig für POST und manchmal PATCH-Endpunkte, die Ressourcen anlegen oder Seiteneffekte mit echten Folgen auslösen: Zahlungen, Bestellungen, das Versenden einer E-Mail oder SMS, das Bereitstellen von Infrastruktur. Wäre das Wiederholen einer fehlgeschlagenen Anfrage so oder so harmlos, braucht man die Maschinerie nicht.
Fazit
Idempotency-Keys verwandeln „ist das überhaupt durchgegangen?" – eine Frage, die dein Client nach einem Timeout nicht verlässlich beantworten kann – in ein Nicht-Problem: mit demselben Key wiederholen, dasselbe Ergebnis bekommen, genau einmal. Das Muster ist einfach, aber die Atomarität des Inserts macht es auch unter nebenläufigen Anfragen tatsächlich sicher, nicht nur unter sequenziellen.