Idempotenz und Race Conditions in Zahlungs-APIs: wie Sie sie richtig testen
Ein Praxisleitfaden zur Semantik von Idempotency-Keys, zu Szenarien mit doppelten Anfragen und zu Techniken für Nebenläufigkeitstests von Zahlungs-APIs, mit lauffähigen Python- und SQL-Beispielen.
Eine Zahlungs-API hat eine Aufgabe, bei der sie sich nie irren darf: den richtigen Betrag genau einmal zu bewegen. Netzwerke machen das schwierig. Ein mobiler Client sendet eine Zahlungsanfrage, die Verbindung bricht ab, bevor die Antwort ankommt, und der Client weiß nicht, ob die Zahlung stattgefunden hat. Wiederholt er die Anfrage, zahlt er womöglich doppelt. Wiederholt er sie nicht, zahlt er womöglich gar nicht.
Idempotenz ist die Standardantwort darauf, und fast jede Zahlungs-API gibt heute an, sie zu unterstützen. In Testprojekten ist Idempotenz dennoch die Stelle, an der viele schwerwiegende Zahlungsfehler auftauchen. Ein sequenzieller Happy-Path-Test besteht, während zwei Anfragen im Abstand von 3 Millisekunden zwei Belastungen erzeugen. Dieser Artikel beschreibt, wie korrektes Verhalten aussieht und wie Sie es mit Techniken testen, die tatsächlich Nebenläufigkeit erzeugen.
Was Idempotenz für eine API bedeutet
Im Sinne von HTTP (RFC 9110) ist eine Methode idempotent, wenn die Wiederholung derselben Anfrage auf dem Server dieselbe beabsichtigte Wirkung hat wie das einmalige Senden. GET, PUT und DELETE sind per Definition idempotent. POST ist es nicht, und das Anlegen einer Zahlung ist fast immer ein POST.
APIs schließen diese Lücke mit einem Idempotency-Key: einem eindeutigen Wert, den der Client erzeugt (meist eine UUID) und mit der Anfrage sendet. Der Server speichert den Schlüssel zusammen mit dem Ergebnis der ersten Anfrage. Jede spätere Anfrage mit demselben Schlüssel erhält das gespeicherte Ergebnis, statt erneut ausgeführt zu werden. Stripe hat dieses Muster populär gemacht. Der Entwurf The Idempotency-Key HTTP Header Field der IETF-Arbeitsgruppe HTTPAPI standardisiert den Header-Namen und die wichtigsten Fehlerfälle. Zum Zeitpunkt der Veröffentlichung ist er noch ein Internet-Draft und kein RFC. Betrachten Sie seine Statuscodes daher als Empfehlungen.
POST /v1/payments HTTP/1.1
Host: api.example.test
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 9f1c2d4e-7b8a-4c3e-9d21-5a6b7c8d9e0f
{"amount": "25.00", "currency": "EUR", "destination": "acct_test_123"}
HTTP/1.1 201 Created
Content-Type: application/json
{"id": "pay_8XK2", "status": "pending", "amount": "25.00", "currency": "EUR"}Das sieht einfach aus, doch hinter der Semantik verbergen sich mehrere Entscheidungen, die Ihre API treffen und dokumentieren muss. Jede davon ist ein Testfall.
Die Semantik, die Sie festlegen müssen
- Replay: Eine zweite Anfrage mit demselben Schlüssel und derselben Nutzlast liefert die ursprüngliche Antwort: gleicher Status, gleiche Ressourcen-ID, idealerweise ein byte-identischer Body. Sie erzeugt keine zweite Ressource und liefert kein neues „200 OK“ mit anderem Inhalt.
- Wiederverwendung des Schlüssels mit anderer Nutzlast: Derselbe Schlüssel mit anderem Body (etwa einem anderen Betrag) ist fast immer ein Fehler im Client. Der Server sollte die Anfrage ablehnen, statt die erste Antwort erneut zu liefern. Der IETF-Entwurf schlägt
422 Unprocessable Contentvor. - Gleichzeitige Duplikate: Während die erste Anfrage noch verarbeitet wird, trifft ein Duplikat ein. Der Server darf es nicht parallel verarbeiten. Der Entwurf schlägt
409 Conflictvor. Warten und anschließendes Replay ist ebenfalls zulässig, sofern dokumentiert. - Fehlender Schlüssel: Verlangt der Endpunkt einen Schlüssel, erhält eine Anfrage ohne Schlüssel
400 Bad Request. - Geltungsbereich: Schlüssel müssen pro Client oder Mandant gelten. Zwei verschiedene Händler, die zufällig dieselbe UUID senden, dürfen nicht die Antworten des jeweils anderen sehen. Das wäre sowohl ein Korrektheitsfehler als auch ein Datenleck.
- Aufbewahrung: wie lange Schlüssel gespeichert werden. Stripe dokumentiert beispielsweise, dass Schlüssel entfernt werden können, sobald sie mindestens 24 Stunden alt sind. Eine Wiederholung nach Ablauf erzeugt eine neue Zahlung, daher müssen Clients dieses Zeitfenster kennen.
- Caching von Fehlern: Wird ein Fehlschlag beim ersten Versuch gespeichert? Validierungsfehler (4xx) sollten in der Regel erneut geliefert werden. Bei einem 5xx, bei dem die Verarbeitung begonnen haben kann oder nicht, muss die API entscheiden, ob eine Wiederholung ausgeführt werden darf. Diese Entscheidung muss mit dem übereinstimmen, was nachgelagert tatsächlich passiert ist.
Sequenzielle Tests: die Basis
Beginnen Sie für jede Regel mit deterministischen, sequenziellen Tests. Sie sind schnell und stabil und gehören bei jedem Build in die CI:
# pytest -q test_idempotency.py (needs: pip install pytest httpx)
import os
import uuid
import httpx
import pytest
BODY = {"amount": "25.00", "currency": "EUR", "destination": "acct_test_123"}
@pytest.fixture
def client():
with httpx.Client(
base_url=os.environ["BASE_URL"],
headers={"Authorization": f"Bearer {os.environ['API_TOKEN']}"},
timeout=30,
) as c:
yield c
def post_payment(client, key, body=BODY):
headers = {"Idempotency-Key": key} if key else {}
return client.post("/v1/payments", json=body, headers=headers)
def test_replay_returns_original_response(client):
key = str(uuid.uuid4())
first = post_payment(client, key)
second = post_payment(client, key)
assert first.status_code == 201
assert second.status_code == first.status_code
assert second.json()["id"] == first.json()["id"]
def test_same_key_different_payload_is_rejected(client):
key = str(uuid.uuid4())
assert post_payment(client, key).status_code == 201
changed = {**BODY, "amount": "250.00"}
r = post_payment(client, key, changed)
# The IETF draft suggests 422; what matters is that your API documents
# a specific status and never silently returns the first payment.
assert r.status_code == 422
def test_missing_key_is_rejected_when_required(client):
r = post_payment(client, key=None)
assert r.status_code == 400
def test_new_key_creates_new_payment(client):
a = post_payment(client, str(uuid.uuid4()))
b = post_payment(client, str(uuid.uuid4()))
assert a.json()["id"] != b.json()["id"]Diese Tests finden fehlende Implementierungen, falsche Statuscodes und Replays, die andere IDs liefern. Race Conditions finden sie nicht, weil jede Anfrage abgeschlossen ist, bevor die nächste beginnt. Die meisten echten Vorfälle mit doppelten Zahlungen sind Races.
Gleichzeitige Duplikate testen
Eine typische fehlerhafte Implementierung sieht so aus: prüfen, ob der Schlüssel existiert → falls nicht, Zahlung verarbeiten → Schlüssel speichern. Zwei gleichzeitig eintreffende Anfragen bestehen beide die Prüfung, bevor eine von ihnen den Schlüssel speichert, und beide verarbeiten die Zahlung. Das ist ein klassisches Check-then-Act-Race, das ein sequenzieller Test nicht sehen kann.
Um es zu testen, brauchen Sie Anfragen, die sich auf dem Server tatsächlich überschneiden. Sie in einer Schleife abzufeuern, genügt nicht. Verwenden Sie eine Barriere, damit alle Anfragen an der Startlinie warten und gemeinsam freigegeben werden:
"""Fire N identical requests with the same Idempotency-Key at (nearly) the same
instant and assert that at most one payment was created.
Requires Python 3.11+ (asyncio.Barrier) and: pip install httpx"""
import asyncio
import os
import uuid
import httpx
BASE = os.environ["BASE_URL"]
TOKEN = os.environ["API_TOKEN"]
N = 20
async def main() -> None:
key = str(uuid.uuid4())
body = {"amount": "25.00", "currency": "EUR", "destination": "acct_test_123"}
headers = {"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}
barrier = asyncio.Barrier(N)
limits = httpx.Limits(max_connections=N, max_keepalive_connections=N)
async with httpx.AsyncClient(base_url=BASE, timeout=30, limits=limits) as client:
# Warm up N connections first so TLS handshakes don't stagger the burst.
await asyncio.gather(*(client.get("/health") for _ in range(N)))
async def one() -> httpx.Response:
await barrier.wait() # all N coroutines are released together
return await client.post("/v1/payments", json=body, headers=headers)
responses = await asyncio.gather(*(one() for _ in range(N)))
statuses = sorted(r.status_code for r in responses)
ids = {r.json()["id"] for r in responses if r.status_code in (200, 201)}
print("statuses:", statuses)
print("distinct payment ids:", ids)
assert len(ids) <= 1, f"duplicate payments created: {ids}"
assert set(statuses) <= {200, 201, 409}, f"unexpected statuses: {statuses}"
if __name__ == "__main__":
asyncio.run(main())Details, die diesen Test zuverlässig machen:
- Verbindungen vorher aufwärmen. Der Aufbau einer TLS-Verbindung dauert einige zehn Millisekunden, oft länger als das Race-Fenster. Ohne Aufwärmen ist die erste Anfrage praktisch allein.
- Eine Verbindung pro Anfrage. Bei HTTP/1.1 werden Anfragen auf einer einzelnen Verbindung serialisiert. Das Verbindungslimit des Clients muss mindestens N betragen.
- Wiederholen. Ein Race, das einmal in 30 Versuchen auftritt, ist bei entsprechendem Volumen trotzdem ein Produktionsvorfall. Führen Sie den Burst 50- bis 100-mal mit neuen Schlüsseln aus und lassen Sie den Test bei jedem Duplikat fehlschlagen.
- Prüfen Sie die Seiteneffekte, nicht nur die Antworten. Die Antworten können unauffällig sein, während das Hauptbuch zwei Einträge enthält. Fragen Sie nach dem Burst das Hauptbuch, die PSP-Sandbox oder die Datenbank ab und prüfen Sie, dass für den Schlüssel genau eine Geldbewegung existiert.
Für schnelle manuelle Prüfungen funktioniert die Shell-Variante überraschend gut:
# 20 parallel POSTs with the same key; prints a count per status code
KEY=$(uuidgen)
seq 1 20 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
-X POST "$BASE_URL/v1/payments" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{"amount":"25.00","currency":"EUR","destination":"acct_test_123"}' \
| sort | uniq -cEine korrekte Implementierung gibt etwa 1 201 und 19 409 aus (oder 20 201, wenn gleichzeitige Duplikate warten und ein Replay erhalten). Sehen Sie 20 201, prüfen Sie, ob alle zwanzig Antworten dieselbe Zahlungs-ID tragen. Ist das nicht der Fall, haben Sie einen Fehler mit doppelten Zahlungen gefunden.
Ist das Race-Fenster sehr schmal, hilft eine spezielle Technik. James Kettles Forschung zu Single-Packet-Angriffen aus dem Jahr 2023 hat gezeigt, dass mehrere HTTP/2-Anfragen, die im selben TCP-Paket abgeschlossen werden, den Großteil des Netzwerk-Jitters eliminieren. Der Repeater von Burp Suite („send group in parallel“) und die Erweiterung Turbo Intruder setzen dies um. Das ist nützlich für sicherheitsorientierte Tests von Idempotenz und Limits.
Über Duplikate hinaus: Races auf gemeinsamem Zustand
Idempotency-Keys schützen davor, dass dieselbe Anfrage zweimal eintrifft. Gegen verschiedene Anfragen, die um dieselbe Ressource konkurrieren, helfen sie nicht. Der klassische Fall ist Double Spending: Zwei Abhebungen, jede für sich gültig, konkurrieren um denselben Kontostand.
"""Two different withdrawals race against one balance.
Account starts at 100.00 EUR; each withdrawal is 80.00 EUR.
Exactly one may succeed, and the final balance must be 20.00 EUR."""
import asyncio
import os
import uuid
import httpx
BASE, TOKEN = os.environ["BASE_URL"], os.environ["API_TOKEN"]
ACCOUNT = os.environ["TEST_ACCOUNT_ID"] # seeded with a balance of 100.00
async def main() -> None:
barrier = asyncio.Barrier(2)
auth = {"Authorization": f"Bearer {TOKEN}"}
async with httpx.AsyncClient(base_url=BASE, headers=auth, timeout=30) as client:
async def withdraw() -> httpx.Response:
await barrier.wait()
return await client.post(
f"/v1/accounts/{ACCOUNT}/withdrawals",
json={"amount": "80.00", "currency": "EUR"},
headers={"Idempotency-Key": str(uuid.uuid4())}, # distinct keys!
)
r1, r2 = await asyncio.gather(withdraw(), withdraw())
balance = (await client.get(f"/v1/accounts/{ACCOUNT}")).json()["balance"]
ok = [r for r in (r1, r2) if r.status_code == 201]
print(r1.status_code, r2.status_code, "balance:", balance)
assert len(ok) == 1, "both or neither withdrawal succeeded"
assert balance == "20.00", f"lost update or overdraft: balance={balance}"
if __name__ == "__main__":
asyncio.run(main())Beachten Sie, dass die beiden Anfragen unterschiedliche Schlüssel verwenden. Aus Sicht der API sind es legitime, getrennte Vorgänge. Sind beide erfolgreich und endet der Kontostand bei −60,00, hat das System einen Lost-Update- oder Überziehungsfehler. Häufige Ursachen sind ein Read-Modify-Write auf dem Kontostand ohne Zeilensperre und eine Saldoprüfung, die in einer anderen Transaktion läuft als die Belastung.
Weitere Races auf gemeinsamem Zustand, die Sie in Zahlungssystemen testen sollten:
- Limits: Tageslimits und Velocity-Regeln, die gegen Zähler geprüft werden, die erst nach der Zahlung aktualisiert werden.
- Erstattungen: zwei Teilerstattungen, die zusammen den ursprünglichen Betrag übersteigen.
- Capture/Void: ein Capture und ein Void, die gleichzeitig für dieselbe Autorisierung eintreffen.
- Gutscheine und Einmal-Tokens: ein Einmalcode, der parallel zweimal eingelöst wird.
- Webhooks: dasselbe Provider-Ereignis wird zweimal zugestellt (Provider wiederholen Zustellungen bewusst) und von zwei Workern gleichzeitig verarbeitet. Deduplizieren Sie anhand der Ereignis-ID des Providers mit einem Unique Constraint, nicht mit einem Set im Arbeitsspeicher.
Wie eine korrekte serverseitige Behandlung aussieht
Als Testerin oder Tester müssen Sie die Korrektur nicht implementieren. Das robuste Muster zu kennen, hilft aber, Tests gezielt auf Schwachstellen zu richten. Der zuverlässige Ansatz besteht darin, die Datenbank atomar entscheiden zu lassen, wer gewinnt:
CREATE TABLE idempotency_keys (
tenant_id uuid NOT NULL,
key text NOT NULL,
request_hash bytea NOT NULL, -- hash of method + path + body
status text NOT NULL DEFAULT 'in_progress', -- or 'completed'
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, key)
);
-- First writer wins. A concurrent duplicate gets zero rows back and must
-- then read the existing row: in_progress -> 409, completed -> replay.
INSERT INTO idempotency_keys (tenant_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, key) DO NOTHING
RETURNING key;Die erste Anfrage fügt die Zeile ein und fährt fort. Gleichzeitige Duplikate laufen in den Primärschlüsselkonflikt, erhalten keine Zeile zurück, lesen die vorhandene Zeile und liefern 409, solange sie in_progress ist, bzw. die gespeicherte Antwort, sobald sie completed ist. Der Request-Hash ermöglicht die Prüfung „gleicher Schlüssel, andere Nutzlast“.
Daraus ergeben sich gezielte Tests:
- Absturz mittendrin. Beenden Sie den Worker (oder injizieren Sie einen Fehler), nachdem der Schlüssel als
in_progressgespeichert wurde, aber vor dem Abschluss. Bleibt eine Wiederholung dauerhaft bei 409 hängen? Eine robuste Implementierung hat einen Wiederherstellungspfad, etwa ein Lock-Timeout oder einen Abgleich mit dem nachgelagerten Provider. - Timeout, dann Wiederholung. Lassen Sie den PSP-Stub erst nach dem Client-Timeout antworten. Der Client wiederholt die Anfrage mit demselben Schlüssel. Das Ergebnis muss genau eine Zahlung sein, und die Wiederholung muss letztlich das ursprüngliche Ergebnis liefern.
- Nachgelagerte Idempotenz. Gibt Ihr Dienst beim Aufruf eines PSP einen eigenen Idempotency-Key weiter? Andernfalls ist zwar Ihre API idempotent, aber Ihr Provider belastet doppelt, wenn Ihr Dienst eine Anfrage wiederholt.
- Geltungsbereich des Schlüssels. Senden Sie denselben Schlüssel von zwei Mandanten. Beide Anfragen müssen unabhängig voneinander erfolgreich sein, und keiner darf die Antwort des anderen sehen.
Vergessen Sie die Client-Seite nicht
Serverseitige Idempotenz hilft nur, wenn Clients sie korrekt nutzen, und Fehler in Clients sind häufig. Prüfen und testen Sie die SDKs und Apps, die die API aufrufen:
- Erzeugen Sie den Schlüssel einmal pro logischem Vorgang, vor dem ersten Versuch, und verwenden Sie ihn für jede Wiederholung dieses Vorgangs. Ein Client, der bei jeder Wiederholung eine neue UUID erzeugt, hat die Idempotenz abgeschaltet, ohne dass es jemand bemerkt.
- Speichern Sie den Schlüssel dauerhaft, wenn der Vorgang den Prozess überdauern kann: Eine mobile App, die mitten in einer Zahlung beendet wird, sollte nach dem Neustart mit demselben Schlüssel wiederholen.
- Wiederholen Sie nur, was sicher wiederholt werden kann: Netzwerkfehler, Timeouts, 409 und 5xx, mit Backoff und Jitter. Wiederholen Sie 422 oder andere 4xx-Fehler nicht blind.
- Leiten Sie Schlüssel nie allein aus dem Inhalt der Nutzlast ab. Zwei tatsächlich getrennte Zahlungen über 25,00 EUR an denselben Empfänger würden sonst kollidieren.
Umsetzung in einem Testplan
Ein kompakter Plan für einen Zahlungsendpunkt:
- Sequenzielle Semantiktests (Replay, abweichende Nutzlast, fehlender Schlüssel, Geltungsbereich, Ablauf) bei jedem Build in der CI.
- Bursts mit gleichzeitigen Duplikaten über aufgewärmte Verbindungen, 50- bis 100-mal wiederholt, mit Prüfungen im Hauptbuch. Nächtlich oder vor jedem Release ausführen.
- Race-Tests auf gemeinsamem Zustand für Kontostände, Limits, Erstattungen und Capture/Void.
- Fehlerinjektion: Absturz eines Workers mitten in der Anfrage, nachgelagerter Timeout, doppelte Webhooks.
- Abgleich nach einem Lasttest: Summen im Hauptbuch gegenüber den Berichten der PSP-Sandbox, Abweichungen pro Transaktion aufgelistet.
Keiner dieser Tests erfordert exotische Werkzeuge. Sie erfordern gezielte Nebenläufigkeit, Prüfungen der Seiteneffekte statt der Statuscodes und genügend Wiederholungen, um ein Race zu finden, das nur gelegentlich auftritt. Enthält Ihre aktuelle Testsuite keinen davon, ist der Fehler mit doppelten Zahlungen möglicherweise schon vorhanden und nur noch nicht gefunden.
Möchten Sie einen zweiten Blick auf Ihr System?
Fordern Sie den kostenlosen 20-Minuten-Check an: kurze schriftliche Befunde, nicht-invasiv, unverbindlich.