Verständnis von Idempotenzschlüsseln in Node.js-POST-Endpunkten
Erklärt, warum POST-Anfragen bei erneuten Versuchen unvorhersehbar fehlschlagen und wie von der Client-Seite generierte Idempotenzschlüssel es Node.js-APIs ermöglichen, doppelte Anfragen sicher zu verarbeiten.
Idempotenz ist ein Begriff, den man in der Dokumentation von Zahlung-APIs häufig findet – meist begleitet von einer Wörterbuchdefinition, die jeder nur überfliegt, ohne sie wirklich zu verstehen. Im Folgenden wird versucht, ihn anhand der Fragen zu erklären, die Entwickler tatsächlich stellen, wenn sie auf ihn in Produktcode stoßen, anstatt der abstrakten Version aus einem Lehrbuch.
Was bedeutet „idempotent“ eigentlich, wenn man Code schreibt und nicht ein Glossar liest?
Eine Operation gilt als idempotent, wenn ihr Ausführung einmal denselben Endzustand erzeugt wie ihre Ausführung fünfmal in Folge mit exakt denselben Eingaben. Nehmen wir PUT /users/8/name mit einem Body von { "name": "Jane" }: Egal, ob man sie einmal oder fünfmal aufruft, der Name des Benutzers bleibt „Jane“ und es sammelt sich nichts an. Im Vergleich dazu: POST /orders mit einem Payload, der dazu dient, eine neue Bestellung zu erstellen – wenn man das fünfmal aufruft, erhält man wahrscheinlich fünf separate Bestellungen statt einer, weil nichts an der Operation verhindert, dass mehrere Bestellungen übereinander angelegt werden.
Warum ist das insbesondere bei POST-Anfragen so problematisch?
POST ist in der Regel das Verfahren, das zur Erstellung von Inhalten verwendet wird, und Netzwerke weisen eine bestimmte Art des Ausfalls auf, die dies gefährlich macht: Eine Anfrage kann auf der Serverseite erfolgreich abgeschlossen werden, während der Client davon nichts erfährt, weil die Antwort selbst unterwegs verloren geht. Aus Sicht des Clients sieht er lediglich einen Zeitüberschreitungsfehler. Er hat keine Möglichkeit herauszufinden, ob die Anfrage tatsächlich durchgeführt wurde, also tut er das Einzige, was sinnvoll ist: Er versucht es erneut.
// the client's perspective, roughly
async function submitOrder(payload) {
try {
return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
} catch {
return submitOrder(payload); // did the first one actually fail, or just the response?
}
}
Falls das Endpunkt /orders nicht so konzipiert ist, dass er solche Wiederholungsversuche verkraftet, wird der Kunde für einen einzigen Kauf zweimal berechnet – und keines der beiden Seiten trägt offensichtlich die Schuld. Aus Sicht des Clients ist die Anfrage tatsächlich fehlgeschlagen. Für den Server hingegen war sie erfolgreich.
Was ist also nötig, um einen POST-Endpunkt in Node tatsächlich idempotent zu machen?
Die übliche Lösung besteht darin, dass der Client für jede logische Operation eine eindeutige Schlüssel erstellt, diesen als Header hinzufügt und der Server diesen Schlüssel verwendet, um eine erneut gesendete Anfrage als dieselbe Anfrage zu erkennen, die bereits verarbeitet wurde, anstatt sie als etwas Neues zu behandeln.
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
if (existing) {
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
const order = await createOrder(req.body);
await db.query(
"INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
[idempotencyKey, JSON.stringify(order), 201]
);
res.status(201).json(order);
});
Es ist die Aufgabe des Clients, denselben Schlüssel jedes Mal zu verwenden, wenn er dieselbe logische Anfrage erneut sendet – in der Regel ein einmal erstellter UUID, unmittelbar bevor der erste Versuch abgesendet wird. Die Aufgabe des Servers ist einfacher: Er erkennt einen bereits gesehenen Schlüssel und gibt das gespeicherte Ergebnis zurück, anstatt die Arbeit erneut durchzuführen.
Wer soll den Idempotenzschlüssel erzeugen, der Client oder der Server?
Das muss der Client sein, und das überrascht viele Menschen, weil ihr Instinkt etwas anderes sagt. Wenn der Server die Schlüssel erzeugen würde, käme bei jeder Wiederholungversuch ein neuer Schlüssel, was das gesamte Verfahren nutzlos machen würde – der Server hätte keinen Anhaltspunkt, um einen Wiederholungsversuch von einer neuen Anfrage zu unterscheiden. Der Schlüssel muss bereits vor dem Versand des ersten Versuchs existieren, genau damit derselbe Wert erneut verwendet werden kann, falls dieser Versuch wiederholt werden muss.
Was passiert, wenn zwei identische Anfragen buchstäblich zur gleichen Zeit eintreffen, anstatt nacheinander?
Das ist der Teil, bei dem fast jeder erste Versuch mit diesem Muster fehlschlägt. Der zuvor gezeigte einfache Ansatz „überprüfen, dann einfügen“ beinhaltet bereits eine Ressourcenkonkurrenzsituation: Zwei Anfragen mit derselben Schlüsselzahl können beide ihren SELECT-Befehl ausführen, beide ergeben leere Ergebnisse und beides geht anschließend daran, eine Bestellung zu erstellen – was den Zweck des Schlüssels völlig untergräbt.
// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"]; try {
await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
} catch (err) {
if (err.code === "23505") { // unique constraint violation
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
throw err;
}
const order = await createOrder(req.body);
await db.query(
"UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
[JSON.stringify(order), 201, idempotencyKey]
);
res.status(201).json(order);
});
Durch das Setzen einer eindeutigen Beschränkung auf die key-Spalte wird die Entscheidungsfindung von der Anwendungslogik auf die Datenbank selbst verlagert: Wenn zwei gleichzeitige Anfragen kollidieren, entscheidet die Datenbank, welche Anfrage gewinnt, und die andere erhält einen klaren, fängbaren Fehler anstelle davon, stillschweigend durchzukommen. Ein if-Statement in Ihrem Route-Handler kann diesen Unterschied allein nicht ausgleichen – Konkurrenzprobleme dieser Art müssen auf der Ebene gelöst werden, die den Zugriff tatsächlich serialisiert, und das ist die Datenbank, nicht eine bedingte Überprüfung in Ihrem Code.
Gilt das auch für GET-Anfragen?
Nicht auf dieselbe Weise, und das führt bei den Leuten regelmäßig zu Verwirrung. GET soll aus Designgründen bereits idempotent sein – es sollte nichts ändern, weshalb ein freies Wiederholen im Grunde sicher ist, ohne besondere Handhabung erforderlich zu machen. Das Idempotency-Key-Muster existiert speziell für Operationen, die Zustände erstellen oder ändern, bei denen ein unvorsichtiges Wiederholen die Auswirkungen verdoppeln würde. Wenn ein GET-Endpunkt nicht bereits sicher für wiederholte Aufrufe ist, liegt das eigentliche Problem darin, dass er Nebeneffekte auslöst, die nach den Semantiken von GET überhaupt nicht zulässig sind.
Wie lange sollte eine Idempotency-Key gültig bleiben?
Idealerweise lange genug, um realistische Szenarien von Wiederholungsversuchen abzudecken, aber nicht so lange, dass gespeicherte Schlüssel endlos anwachsen. Viele Zahlungsplattformen wählen einen Zeitraum von 24 Stunden bis zu einigen Tagen. Ein geplanter Reinigungsjob kann anschließend abgelaufene Einträge löschen:
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
Festlegt man das Zeitfenster zu eng, könnte ein erneuter Versuch, der aus einem berechtigten Grund verzögert wird – zum Beispiel weil das Telefon eines Kunden während des Bezahls für zehn Minuten kein Signal hat – außerhalb dieses Zeitfensters liegen und tatsächlich zu einer Doppelverarbeitung führen. Lässt man das Zeitfenster hingegen unendlich offen, wächst die Tabelle ständig weiter, ohne dass daraus echte Vorteile entstehen.
Ist dieses Muster nur für Zahlungssysteme von Bedeutung?
Zahlungen sind in der Regel der Bereich, in dem Menschen diese Lektion zuerst lernen – hauptsächlich weil eine doppelte Abrechnung die Art von Fehler ist, der innerhalb einer Stunde zu einem wütenden E-Mail-Anschreiben des Kunden führt. Doch das zugrundeliegende Problem – ein Kunde, der nicht unterscheiden kann zwischen „Mein Antrag ist fehlgeschlagen“ und „Mein Antrag war erfolgreich, aber ich habe nie eine Rückmeldung erhalten“ – tritt überall auf, wo Nebeneffekte auftreten: das Versenden einer E-Mail, der Auslösen eines Webhooks, die Einrichtung eines neuen Kontos oder der Start einer Hintergrundaufgabe. Jede Operation, bei der ein erneuter Versuch sinnvoll ist und deren Ausführung zweimal schlimmer wäre als gar keine Ausführung, eignet sich gut für diesen Ansatz.
Verwandte Artikel
- Layeriertes Node.js-API-Design: Vom überladenen Controller zur sauberen Architektur — Erfahren Sie, wie Sie eine Node.js-API in Controller-, Service- und Datenzugriffsschichten umstrukturieren können, um verwickelte Geschäftslogik, inkonsistente Fehler sowie Skalierungsprobleme zu beheben.
- 20 Node.js-Muster, die Ausfälle von Produktionsservern verhindern — Lernen Sie 20 praktische Node.js-Muster – von Fehlerbehandlung über sanften Herunterfahren bis hin zu Verbindungspooling –, die Abstürze verhindern, bevor ein Neustart notwendig wird.