Idempotenz erklärt – und was sie für Ihre API bedeutet
Idempotency-Keys erklärt: doppelte API-Requests verhindern, Race Conditions beheben und POSTs mit atomaren Claims und Transaktionen sicher wiederholen.
Eine Operation ist idempotent, wenn ihre mehrfache Ausführung das System im selben Zustand hinterlässt wie eine einmalige Ausführung. Senden Sie dieselbe Anfrage zweimal, passiert nichts Zusätzliches: keine zweite Bestellung, keine zweite Abbuchung, kein zweites Konto.
Der Begriff taucht üblicherweise an einer von zwei Stellen auf: bei einer doppelten Abbuchung in Produktion oder bei einer Payment-API, die einen Idempotency-Key-Header verlangt, ohne groß zu erklären, was der Server damit anstellt. Dieser Artikel behandelt beide Hälften dieses Vertrags: was der Client mit dem Key tut und was der Server tut, damit eine wiederholte Anfrage tatsächlich harmlos ist und nicht nur meistens harmlos.
Die wichtigsten Erkenntnisse
- Ein Idempotency Key hat drei Zustände, nicht zwei: nicht vorhanden, in Bearbeitung (in flight) und abgeschlossen. Eine Anfrage, die den Key im Zustand „in flight“ vorfindet, sollte ein 409 Conflict erhalten und keine zweite Ausführung auslösen.
- Zu prüfen, ob ein Key existiert, und ihn erst danach zu schreiben, ist genau die Race Condition; der einzig sichere Anspruch auf den Key ist ein einzelnes atomares Insert, bevor irgendeine Arbeit beginnt.
- Das gespeicherte Ergebnis muss in derselben Datenbanktransaktion committet werden wie die fachliche Änderung; ein getrenntes Commit verschiebt die Race Condition lediglich.
- Der Client erzeugt den Key vor dem ersten Versuch, verwendet ihn bei jedem Retry erneut und leitet ihn niemals aus einem Hash des Request-Bodys ab.
- Testen Sie, indem Sie zwei identische Anfragen im selben Moment absetzen. Ein Duplikat-Test, der sie nacheinander ausführt, ist auch dann grün, wenn der Code eine Race Condition enthält.
Was verhindert Idempotenz?
Idempotenz schützt Sie vor doppelten Anfragen – und doppelte Anfragen sind Alltag, keine Ausnahmeerscheinung. Ein Nutzer klickt zweimal auf „Absenden“, bevor die Seite reagiert. Eine Client-Bibliothek läuft beim Warten auf eine Antwort in einen Timeout und sendet erneut. Ein Proxy oder Service Mesh wiederholt die Anfrage nach einem Verbindungsabbruch, ohne dass die Anwendung davon erfährt. In allen Fällen kann die erste Anfrage erfolgreich gewesen sein, sodass die zweite – naiv verarbeitet – eine zweite Bestellung erzeugt oder zweimal Geld bewegt. Session Replays von Fehlern durch Doppelklick-Submits zeigen meist die banale Variante: Der Nutzer drückt erneut auf „Absenden“, während der Spinner noch läuft – das ist die clientseitige Hälfte genau des Problems, das der Key auf dem Server löst.
Warum Retries unvermeidlich sind
Ein Retry ist keine Fehlfunktion. HTTP-Clients, Mobile Apps und die Infrastruktur dazwischen senden Anfragen nach Timeouts bewusst erneut, denn eine verlorene Antwort ist von einer verlorenen Anfrage nicht zu unterscheiden. Sie können Retries nicht verhindern; Sie können Ihren Endpunkt nur so bauen, dass er dieselbe Anfrage zweimal sicher entgegennehmen kann.
RFC 9110 definiert PUT und DELETE als idempotent, POST hingegen nicht, und PATCH, definiert in RFC 5789, ist ebenfalls nicht idempotent. Das Methoden-Label allein macht Ihren Handler jedoch nie sicher; Idempotenz ist eine Eigenschaft Ihrer Implementierung, nicht des Verbs.
Die Hälfte des Vertrags, die dem Client gehört
Der Client erzeugt den Idempotency Key vor dem ersten Versuch, sendet bei jedem Retry derselben Operation denselben Key und verwendet einen neuen Key nur für eine tatsächlich neue Operation. Eine Zufallszeichenkette mit hoher Entropie, etwa eine UUID, genügt. Stripes Empfehlung lautet: eine V4-UUID, maximal 255 Zeichen und nichts Sensibles im Key selbst – keine E-Mail-Adressen oder andere personenbezogene Kennungen, denn Keys landen in Logs.
Leiten Sie den Key niemals aus einem Hash des Request-Bodys ab. Zwei Bestellungen, die zufällig identisch sind – etwa derselbe Kunde, der denselben Artikel zweimal hintereinander kauft –, würden auf denselben Wert hashen und zu einer einzigen zusammengeführt. Ein Hash sagt Ihnen, ob zwei Payloads übereinstimmen; ein Key sagt Ihnen, welche Operation der Aufrufer gemeint hat. Das sind zwei verschiedene Aufgaben. Den Key aus etwas Stabilem abzuleiten, mit dem der Nutzer ohnehin gerade interagiert – etwa einer Cart-ID –, funktioniert dagegen einwandfrei, weil der Warenkorb stellvertretend für die Operation steht.
Beachten Sie: Der Idempotency-Key-Header ist eine Branchenkonvention, kein verabschiedeter Standard. Der Entwurf der IETF-httpapi-Arbeitsgruppe ist mit Revision 07 abgelaufen, ohne RFC zu werden, sodass jeder Anbieter seine eigene Semantik definiert.
Die Hälfte des Servers: den Key atomar beanspruchen
Ein Idempotency Key hat drei Zustände, nicht zwei: nicht vorhanden, in Bearbeitung und abgeschlossen. Die meisten fehlerhaften Implementierungen modellieren nur zwei. Sie prüfen, ob der Key existiert, führen den Handler aus und speichern anschließend das Ergebnis. Damit bleibt ein Zeitfenster, in dem zwei gleichzeitige Retries beide nichts vorfinden und beide ausgeführt werden. Die Lösung besteht darin, den Key mit einem einzelnen atomaren Insert zu beanspruchen, bevor irgendeine Arbeit beginnt:
INSERT INTO idempotency_keys
(tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;
In PostgreSQL überspringt ON CONFLICT DO NOTHING das Insert, und RETURNING liefert keine Zeile, wenn der Key kollidiert. Null zurückgegebene Zeilen bedeuten also, dass eine andere Anfrage den Key besitzt. Lesen Sie dann die vorhandene Zeile: Ist ihr Zustand complete, spielen Sie das gespeicherte Ergebnis erneut aus; ist sie noch in_flight, geben Sie 409 Conflict zurück, statt ein zweites Mal auszuführen. Das entspricht dem dokumentierten Verhalten der Anbieter: Stripe gibt 409 Conflict zurück, wenn ein Key wiederverwendet wird, während die erste Anfrage noch läuft, und speichert diesen Konflikt nicht am Key, sodass der Client später erneut anfragen kann. Stripe kennzeichnet eine wiederholt ausgelieferte Antwort außerdem mit dem Header Idempotent-Replayed: true – eine günstige Höflichkeit, die nachahmenswert ist.
Ergebnis gemeinsam mit der fachlichen Änderung committen
Das gespeicherte Ergebnis und die fachliche Änderung müssen in derselben Datenbanktransaktion committet werden. Sie getrennt zu schreiben, beseitigt die Race Condition nicht, sondern verschiebt sie in die Lücke zwischen den beiden Commits. Stirbt der Prozess nach der Abbuchung, aber bevor der Key aktualisiert wurde, ist das Geld geflossen, während die Zeile noch in_flight ausweist.
BEGIN;
INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);
UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;
COMMIT;
Entweder existieren beide Zeilen oder keine – genau darum geht es.
Was sollten Sie zum Key speichern, und wie lange?
Speichern Sie, was der Handler erzeugt hat – einschließlich seiner Fehler. Nach Stripes Idempotenz-Regeln werden Statuscode und Body des ersten Versuchs aufbewahrt und bei Wiederverwendung erneut ausgeliefert, Fehlerantworten und 500er inklusive. Einen echten Fehler erneut auszuliefern, ist ehrlicher, als die Operation stillschweigend ein zweites Mal auszuführen. Die eigentliche Grenze verläuft bei allem, was abgelehnt wird, bevor der Handler läuft. Rate Limiting und Authentifizierung sitzen vor der Idempotenz-Schicht, sodass diese Antworten nie an den Key gebunden werden und wiederholbar bleiben.
Drei Speicheroptionen, kurz gefasst:
- Vollständige Antwort. Am einfachsten exakt wiederzugeben; der Speicherbedarf wächst mit der Payload-Größe.
- Ressourcen-Referenz. Speichern Sie die ID der angelegten Bestellung und bauen Sie die Antwort neu auf; schlanker, erfordert aber einen zusätzlichen Lookup.
- Marker plus Request-Fingerprint. Minimaler Speicherbedarf; nur praktikabel, wenn die Antwort neu berechnet werden kann, und der Fingerprint wird zur Pflicht statt zur Option.
Vier Regeln gelten unabhängig von der gewählten Option. Legen Sie das Unique Constraint auf (tenant_id, key) statt auf den Key allein, damit ein Mandant nicht mit den Keys eines anderen kollidieren oder nach ihnen fischen kann. Setzen Sie ein Ablaufdatum: Stripe räumt Keys nach Ablauf der 24-Stunden-Marke auf, und das Prinzip lautet, das Retry-Fenster zu überdauern, ohne die Tabelle unbegrenzt wachsen zu lassen. Versehen Sie In-Flight-Zeilen mit einem Lease (die Spalte locked_until oben), damit ein mitten in der Anfrage abgestürzter Prozess Retries nicht dauerhaft blockiert. Und weisen Sie jeden Retry zurück, dessen Fingerprint nicht zum gespeicherten passt. Derselbe Key mit einem anderen Body deutet auf einen Client-Bug hin, und eine unpassende Antwort zurückzuliefern wäre das schlechtere Ergebnis.
Wie testet man Idempotenz richtig?
Setzen Sie zwei identische Anfragen mit demselben Key im selben Moment ab und prüfen Sie anschließend, dass genau eine Ressource existiert. Die Duplikate nacheinander auszuführen, beweist nichts, weil die erste bereits abgeschlossen ist, bevor die zweite nachschaut – Code mit Race Condition besteht diesen Test also.
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o "resp_$i.json" -w "%{http_code}\n" \
-X POST http://localhost:3000/orders \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"cart_id":"c_42","total_cents":1900}' &
done
wait
Prüfen Sie, dass in orders genau eine Zeile für diesen Warenkorb existiert und dass die beiden Statuscodes aus einer 201 und entweder einer wiederholt ausgelieferten 201 oder einer 409 bestehen. Kommen beide Male 201 mit unterschiedlichen Order-IDs zurück, haben Sie die Check-then-Write-Race-Condition.
Drei Dinge, die stimmen müssen
Der Header gibt zwei Systemen lediglich einen gemeinsamen Namen für eine Operation. Die Sicherheit selbst entsteht aus drei Dingen in Ihrer Datenbank: einem Unique Constraint, einem atomaren Anspruch auf den Key und einer Transaktionsgrenze. Bekommen Sie diese drei richtig hin, übersteht Ihr Endpunkt jeden Client, der Retries durchführt – also jeden Client. Derselbe Ansatz lässt sich auf Message-Consumer übertragen, wo At-least-once-Zustellung bedeutet, dass ein Dedupe-Key dieselbe Aufgabe unter anderem Namen erfüllt. Beginnen Sie mit Ihrem gefährlichsten POST-Endpunkt, ergänzen Sie die Key-Tabelle und schreiben Sie den Nebenläufigkeitstest, bevor Sie ihm vertrauen.
FAQs
Brauchen GET- und PUT-Anfragen Idempotency Keys?
In der Regel nicht. RFC 9110 definiert GET als sicher (safe) sowie PUT und DELETE als idempotent, sodass ein wiederholtes PUT, das eine Ressource vollständig ersetzt, auch ohne Key denselben Zustand hinterlässt. Keys sind für POST relevant, wo jede Anfrage etwas Neues erzeugt. Die Ausnahme ist ein PUT- oder DELETE-Handler mit Seiteneffekten, etwa dem Versand einer E-Mail oder dem Auslösen eines Webhooks – der benötigt weiterhin serverseitige Deduplizierung.
Was ist der Unterschied zwischen einem Idempotency Key und einer Request-ID?
Sie verhalten sich bei Retries genau gegenläufig. Eine Request-ID oder Correlation-ID identifiziert einen einzelnen HTTP-Versuch für Logging und Tracing, sodass jeder Retry eine neue erhält. Ein Idempotency Key identifiziert eine beabsichtigte Operation, sodass jeder Retry denselben Key wiederverwendet. Ein Client, der für jeden Retry einen neuen Idempotency Key erzeugt, hebelt die Deduplizierung vollständig aus, und der Server führt die Operation zweimal aus.
Kann ich Idempotency Keys in Redis statt in PostgreSQL speichern?
Für den atomaren Anspruch ja: SET mit dem NX-Flag beansprucht einen Key in einem einzigen atomaren Schritt und entspricht damit dem Insert-on-Conflict-Muster. Was Redis nicht bieten kann, ist eine einzelne Transaktion, die das Key-Ergebnis gemeinsam mit einer anderswo gespeicherten fachlichen Zeile committet. Ein Absturz zwischen dem Redis-Write und dem Datenbank-Commit öffnet die Race Condition erneut, weshalb es sicherer ist, die Keys in der Fachdatenbank zu halten.
Was passiert, wenn ein Client nach Ablauf des Idempotency Keys einen Retry durchführt?
Der Server behandelt den Retry als völlig neue Anfrage und führt sie erneut aus, was ein Duplikat erzeugen kann. Stripe etwa räumt Keys nach 24 Stunden auf, sodass ein danach wiederverwendeter Key die Operation ein zweites Mal ausführt. Wählen Sie Ihre Aufbewahrungsdauer länger als die längste Retry-Verzögerung, die irgendein Client, eine Queue oder ein Batch-Job plausibel erzeugen kann.