Dein Service stürzt mitten in einem POST /charge-Request ab. Der Client sieht einen Timeout und versucht es erneut. Du hast jetzt zwei Abbuchungen. Der Kunde ist sauer. Die Datenbank ist konsistent. Deine Business-Logik nicht.
Das ist kein Edge Case. Es ist das Standardverhalten verteilter Systeme. Netzwerke verlieren Pakete. Container werden mitten im Request OOM-gekillt. Load Balancer geben 502 für Requests zurück, die das Backend bereits erreicht hatten. Wenn deine API davon ausgeht: „Ich habe einen Fehler bekommen, also ist die Operation definitiv nicht passiert“, baust du eine Bug-Fabrik.
Die At-least-once-Realität, der du nicht entkommen kannst
HTTP ist standardmäßig At-least-once. TCP wiederholt verlorene Pakete. Dein HTTP-Client retryt bei Timeout. Deine Infrastruktur retryt bei 5xx. Jede Ebene geht davon aus, dass die vorherige fehlschlagen könnte, und sendet erneut.
Das Problem sind nicht Retrys. Das Problem sind nicht-idempotente Operationen.
Ein GET ist sicher zu retryen, weil zweimal Lesen dasselbe ist wie einmal Lesen. Ein POST /charge oder POST /orders ist das nicht. Zweimal ausführen erzeugt zwei Ressourcen. Nullmal ausführen verliert einen Verkauf. Auf einem unzuverlässigen Netzwerk kannst du nicht „exactly once“ wählen. Du kannst nur wählen zwischen „at least once“ mit Deduplizierung oder „vielleicht null, vielleicht zwei“ mit Datenkorruption.
Idempotenz ist, wie du „at least once“ sicher machst.
Wie Idempotency-Keys tatsächlich funktionieren
Stripe hat dieses Pattern populär gemacht, aber die Idee ist älter. Der Client generiert einen eindeutigen Key (eine UUID) und sendet ihn in einem Header: Idempotency-Key: <uuid>. Der Server speichert das Tupel (key, request_body, response), bevor er den Side Effect ausführt. Wenn derselbe Key erneut ankommt, gibt der Server die gespeicherte Antwort zurück, ohne die Operation neu auszuführen.
Der zentrale Einblick: Der Server muss den Key speichern, bevor er die Arbeit erledigt, nicht danach. Wenn du zuerst die Charge-Row schreibst und vor dem Speichern des Keys abstürzt, erzeugt der Retry eine zweite Abbuchung. Das Speichern des Idempotency-Keys und die Business-Mutation müssen atomar sein.
In der Praxis bedeutet das eines von zweierlei:
-
Der Idempotency-Store und der Business-Store teilen eine Datenbank-transaction. Du fügst in
idempotency_keysundchargesim selbenBEGIN ... COMMITein. Wenn der Commit erfolgreich ist, existieren beide. Wenn er fehlschlägt, existiert keiner von beiden. -
Der Idempotency-Store ist der Business-Store. Deine
charges-Tabelle hat eineclient_idempotency_key-Spalte mit einerUNIQUE-Constraint. Der Retry scheitert am Unique-Check, und du gibst die bestehende Row zurück.
Option 2 ist einfacher und der Punkt, mit dem die meisten Teams starten sollten.
Eine funktionierende Implementierung mit atomarem Storage
Hier ist ein minimaler, aber kompletter Python-Server mit SQLite. Der Idempotency-Key lebt in derselben transaction wie die Business-Mutation. Wenn der Server nach COMMIT abstürzt, trifft der Retry auf den Cache. Wenn er vor COMMIT abstürzt, wird nichts persistiert und der Retry läuft sicher erneut.
import sqlite3
import json
import uuid
DB_PATH = "/data/inventory.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
idempotency_key TEXT UNIQUE NOT NULL,
created_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS idempotency_responses (
key TEXT PRIMARY KEY,
response_body TEXT NOT NULL,
created_at INTEGER
)
""")
conn.commit()
return conn
def reserve_inventory(conn, idempotency_key, item_id, quantity):
# Step 1: Check for an existing response.
cursor = conn.execute(
"SELECT response_body FROM idempotency_responses WHERE key = ?",
(idempotency_key,)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
# Step 2: Do the work inside a single transaction.
conn.execute("BEGIN")
# Deduct inventory atomically.
conn.execute(
"UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
(quantity, item_id, quantity)
)
if conn.total_changes == 0:
conn.rollback()
return {"error": "insufficient inventory"}
# Record the reservation.
reservation_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
(reservation_id, item_id, quantity, idempotency_key)
)
# Cache the response for retries.
response = {"reservation_id": reservation_id, "status": "reserved"}
conn.execute(
"INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
(idempotency_key, json.dumps(response))
)
conn.commit()
return response
Die idempotency_responses-Tabelle ist das Sicherheitsnetz. Der erste Request führt die Mutation aus, committed das Ergebnis und cached die Antwort. Jeder nachfolgende Request mit demselben Key überspringt die Arbeit und gibt das gecachte JSON zurück. Die Reservation und der Cache-Eintrag werden in derselben transaction geschrieben, sodass sie entweder beide sichtbar oder beide abwesend sind.
Wo das bricht: die Side-Effect-Grenze
Idempotency-Keys handhaben Duplicate Requests vom selben Client. Sie handhaben keine gleichzeitigen Duplicate Requests von verschiedenen Clients. Wenn zwei User auf „Kaufen“ beim letzten Artikel auf Lager klicken, brauchst du immer noch pessimistic locking oder optimistic concurrency control. Der available >= ?-Check im Beispiel oben ist eine primitive Form davon, aber echte Inventory-Systeme brauchen mehr.
Das größere Problem sind Side Effects außerhalb der transaction. Wenn du eine Karte über Stripe belastest, eine E-Mail über SendGrid verschickst und in deine Datenbank schreibst, schützt der Idempotency-Key nur den Datenbank-Part. Die E-Mail könnte zweimal gesendet werden. Die Karte könnte zweimal belastet werden, wenn Stripe’s eigenes Idempotency-Window abgelaufen ist. Wahre Sicherheit erfordert, dass jedes Downstream-System mitmacht.
Deshalb akzeptiert Stripe seinen eigenen Idempotency-Key bei Charge-Creation. Sie deduplizieren auf ihrer Ebene. Du solltest dasselbe auf deiner tun. Reiche denselben Key an jeden idempotenten Downstream-Service durch. Für Services, die das nicht unterstützen, wrape den Call in eine lokale transaction oder akzeptiere das Risiko.
Key-Collisions, TTLs und andere operationale Fallen
UUID4 hat 122 Bit an Zufall. Die Wahrscheinlichkeit einer Collision ist für jedes realistische Volumen vernachlässigbar. Verwende keine sequentiellen Integers, Timestamps oder gehashten Request-Bodies als Keys. Eine client-generierte UUID ist aus gutem Grund der Industry Standard.
Key-Storage wächst ewig, wenn du alte Einträge nicht auslaufen lässt. Setze eine TTL: 24 Stunden ist Standard. Danach lösche alte Keys. Wenn ein Client nach der TTL retryt, bekommt er ein Duplicate. Dokumentiere das. Das Retry-Window und die TTL sind ein Business-Contract, kein technisches Detail.
Der Idempotency-Store muss mindestens so verfügbar sein wie die API. Wenn dein Redis-Cache down ist, kannst du Keys nicht verifizieren. Einige Teams fallen auf „assume new request“ zurück, was während Ausfällen Duplikate erzeugt. Andere rejecten den Request, was sicherer ist, aber einen anderen Failure Mode erzeugt. Hier gibt es kein Free Lunch.
Die Client-Seite ist genauso wichtig
Serverseitige Idempotency-Keys sind nutzlos, wenn der Client sie nicht sendet. Jeder mutierende Request sollte am Call-Site einen Key generieren und bei Timeout mit demselben Key retryen:
import uuid
import requests
def safe_post(url, payload, max_retries=3):
key = str(uuid.uuid4())
for attempt in range(max_retries):
try:
resp = requests.post(
url,
json=payload,
headers={"Idempotency-Key": key},
timeout=10,
)
return resp
except requests.Timeout:
if attempt == max_retries - 1:
raise
# Retry with the SAME key. The server deduplicates.
Der Key muss einmal pro logischer Operation generiert werden, nicht einmal pro HTTP-Attempt. Wenn du bei jedem Retry eine neue UUID generierst, hast du den Punkt verpasst. Der Key ist der Contract, der die Retrys zusammenhält.
FAQ
Was, wenn der Client ein Browser ist und der User die Seite refreshed?
Ein Page Refresh erzeugt einen neuen JavaScript-Context. Der neue Request bekommt einen neuen Idempotency-Key, es sei denn, du persistierst den Key in localStorage oder der URL. Die meisten Teams machen sich für non-critical Flows keine Mühe. Für Payments solltest du das.
Sollten GET-Requests Idempotency-Keys verwenden?
Nein. GET ist per HTTP-Semantik bereits safe. Idempotency-Keys sind für Methoden, die State ändern: POST, PUT, PATCH, DELETE.
Kann ich den Request-Body-Hash als Idempotency-Key verwenden?
Nur wenn der Body deterministisch ist und keine Timestamps oder Random Values enthält. In der Praxis sind client-generierte UUIDs einfacher und zuverlässiger.
Wie lange sollte ich Idempotency-Keys aufbewahren?
Länger als dein längstes Client-Retry-Window. Wenn Clients 60 Sekunden lang retryen, behalte Keys für 24 Stunden. Wenn Clients wegen eines Batch-Jobs vielleicht morgen retryen, behalte Keys für eine Woche.
Mach deinen ersten Request heute idempotent
Füge jedem mutierenden Endpoint einen Idempotency-Key-Header hinzu. Starte mit einem 24-Stunden-Cache. Verwende eine UUID4. Mache den Storage atomar mit deiner business transaction. Das erste Mal, wenn ein Container mitten im Request stirbt und der Client retryt, wirst du froh sein, dass du es getan hast. Die Alternative ist, deinem Finance-Team zu erklären, warum ein einzelner Kunde siebzehn identische Abbuchungen hat.