Sichere POST- und PUT-Requests bei Retries
Ein Netzwerkfehler nach erfolgreicher Zahlung führt ohne Schutzmaßnahme dazu, dass der Client denselben Request wiederholt und eine zweite Zahlung auslöst. Dieser Artikel zeigt, wie Idempotenz-Schlüssel in PHP-APIs implementiert werden, um genau dieses Problem zuverlässig zu verhindern, inklusive Request-Fingerprint, Antwort-Cache und Schutz vor Race Conditions.
Inhaltsverzeichnis
- 1. Warum POST-Requests ohne Idempotenz gefährlich sind
- 2. Was ein Idempotenz-Schlüssel ist
- 3. Serverseitige Speicherung: Fingerprint und Antwort-Cache
- 4. Der Ablauf einer idempotenten Anfrage im Detail
- 5. Eine Idempotency-Middleware in PHP bauen
- 6. Konflikterkennung: unterschiedlicher Body, gleicher Key
- 7. Ablaufzeit und Aufräumen abgelaufener Einträge
- 8. Idempotenz bei nebenläufigen Requests
- 9. Ohne Idempotenz vs. mit Idempotenz-Keys im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum POST-Requests ohne Idempotenz gefährlich sind
HTTP definiert GET, PUT und DELETE als idempotent, ein wiederholter Aufruf soll denselben Zustand herstellen wie ein einzelner Aufruf. POST ist explizit nicht idempotent, jeder Aufruf erzeugt per Definition eine neue Ressource. Genau das wird bei Zahlungsvorgängen und Bestellprozessen zum Problem: schlägt die Netzwerkverbindung nach erfolgreicher serverseitiger Verarbeitung, aber vor Empfang der Antwort ab, weiß der Client nicht, ob die Anfrage tatsächlich verarbeitet wurde. Ein automatischer Retry-Mechanismus wiederholt in diesem Fall den POST-Request und löst eine zweite, unbeabsichtigte Zahlung oder Bestellung aus.
Ein Idempotenz-Schlüssel löst genau dieses Problem: der Client generiert vor dem ersten Versuch einen eindeutigen Schlüssel und sendet ihn bei jedem Wiederholungsversuch derselben logischen Operation identisch mit. Der Server erkennt anhand dieses Schlüssels, ob die Operation bereits ausgeführt wurde, und liefert im Wiederholungsfall die ursprüngliche Antwort zurück, ohne die Operation ein zweites Mal auszuführen.
Dieses Muster ist besonders in Zahlungs-APIs wie Stripe und PayPal etabliert, gilt aber für jede PHP-API, bei der ein wiederholter POST- oder PUT-Request reale, nicht rückgängig machbare Konsequenzen hätte: Bestellungen, Versandbenachrichtigungen, E-Mail-Zustellungen oder Buchungen. Der Idempotenz-Schlüssel macht diese Operationen effektiv sicher gegenüber Netzwerkfehlern und automatischen Wiederholungen.
2. Was ein Idempotenz-Schlüssel ist
Technisch ist ein Idempotenz-Schlüssel eine vom Client generierte, eindeutige Zeichenkette, typischerweise ein UUID Version 4, die im Header Idempotency-Key mit jedem Versuch derselben logischen Anfrage identisch mitgeschickt wird. Wichtig ist, dass der Client den Schlüssel einmalig pro logischer Operation erzeugt, nicht pro HTTP-Request: bei einem Retry desselben Bestellversuchs bleibt der Schlüssel gleich, bei einer neuen, unabhängigen Bestellung wird ein neuer Schlüssel generiert.
Der Server behandelt den Idempotenz-Schlüssel als eindeutigen Identifikator für einen konkreten Verarbeitungsversuch. Beim ersten Eintreffen eines Schlüssels wird die Operation normal ausgeführt und das Ergebnis mit dem Schlüssel verknüpft gespeichert. Trifft derselbe Schlüssel erneut ein, liefert der Server direkt das gespeicherte Ergebnis zurück, ohne die zugrunde liegende Geschäftslogik erneut auszuführen.
3. Serverseitige Speicherung: Fingerprint und Antwort-Cache
Die serverseitige Speicherung für einen Idempotenz-Schlüssel braucht mindestens drei Felder: den Schlüssel selbst, einen Fingerprint des Request-Bodys, und die gespeicherte Antwort inklusive Statuscode. Der Fingerprint, meist ein Hash über Methode, Pfad und Body, dient der Konflikterkennung: derselbe Schlüssel mit unterschiedlichem Body deutet auf einen Client-Bug oder einen Versuch hin, den Schlüssel für eine andere Operation wiederzuverwenden.
Eine relationale Tabelle für Idempotenz-Schlüssel reicht in den meisten PHP-Projekten aus, alternativ eignet sich auch Redis mit Ablaufzeit, wenn ohnehin ein Redis-Cluster für andere Zwecke vorhanden ist. Wichtig ist ein eindeutiger Index auf der Schlüsselspalte, damit gleichzeitige Requests mit demselben Schlüssel nicht doppelt in die Tabelle eingefügt werden können.
-- MySQL/MariaDB table for storing idempotency keys
CREATE TABLE idempotency_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL,
request_fingerprint CHAR(64) NOT NULL,
response_status SMALLINT UNSIGNED NULL,
response_body MEDIUMTEXT NULL,
status ENUM('processing', 'completed') NOT NULL DEFAULT 'processing',
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
UNIQUE KEY uniq_idempotency_key (idempotency_key)
);
4. Der Ablauf einer idempotenten Anfrage im Detail
Der vollständige Ablauf einer idempotenten Anfrage besteht aus vier Schritten: Zunächst prüft der Server, ob der übermittelte Idempotenz-Schlüssel bereits existiert. Existiert er nicht, wird ein neuer Eintrag im Zustand processing angelegt, um parallele Duplikate zu blockieren, danach die eigentliche Geschäftslogik ausgeführt, und abschließend das Ergebnis im Eintrag gespeichert und der Zustand auf completed gesetzt.
Existiert der Schlüssel bereits im Zustand completed, liefert der Server sofort die gespeicherte Antwort zurück, ohne die Geschäftslogik erneut auszuführen. Existiert er im Zustand processing, bedeutet das, dass eine parallele Anfrage mit demselben Schlüssel noch läuft, in diesem Fall sollte der Server einen 409 Conflict zurückgeben, statt die Anfrage ebenfalls zu verarbeiten und dadurch die eigentliche Schutzfunktion des Idempotenz-Schlüssels zu unterlaufen.
5. Eine Idempotency-Middleware in PHP bauen
Die praktische Umsetzung gehört als Middleware oder Vorfilter vor die eigentliche Controller-Logik, damit jeder betroffene Endpunkt automatisch geschützt ist, ohne dass die Geschäftslogik selbst etwas von der Idempotenz-Prüfung wissen muss. Die Middleware liest den Idempotency-Key-Header aus, prüft den Speicher, und entscheidet, ob die eigentliche Anfrage verarbeitet oder eine gespeicherte Antwort zurückgegeben wird.
<?php
declare(strict_types=1);
/**
* Middleware enforcing idempotent processing for POST/PUT requests.
*/
final class IdempotencyMiddleware
{
public function __construct(private readonly IdempotencyStore $store)
{
}
/**
* @param callable(): array{status: int, body: string} $next
* @return array{status: int, body: string}
*/
public function handle(?string $idempotencyKey, string $method, string $path, string $rawBody, callable $next): array
{
if ($idempotencyKey === null || !in_array($method, ['POST', 'PATCH'], true)) {
return $next();
}
$fingerprint = hash('sha256', $method . '|' . $path . '|' . $rawBody);
$existing = $this->store->find($idempotencyKey);
if ($existing !== null) {
if ($existing->fingerprint !== $fingerprint) {
return ['status' => 409, 'body' => '{"title":"Idempotency key reused with a different request"}'];
}
if ($existing->status === 'processing') {
return ['status' => 409, 'body' => '{"title":"Request with this idempotency key is still processing"}'];
}
return ['status' => $existing->responseStatus, 'body' => $existing->responseBody];
}
$this->store->markProcessing($idempotencyKey, $fingerprint);
$result = $next();
$this->store->complete($idempotencyKey, $result['status'], $result['body']);
return $result;
}
}
6. Konflikterkennung: unterschiedlicher Body, gleicher Key
Ein oft übersehener Aspekt bei Idempotenz-Schlüsseln: was passiert, wenn derselbe Schlüssel mit einem inhaltlich anderen Request-Body eintrifft? Ohne Konflikterkennung könnte ein Client versehentlich denselben Schlüssel für zwei unterschiedliche Bestellungen wiederverwenden und dadurch die zweite Bestellung stillschweigend verwerfen, weil der Server die gespeicherte Antwort der ersten Bestellung zurückliefert.
Der Fingerprint-Vergleich im vorherigen Codebeispiel fängt genau diesen Fall ab: weicht der Fingerprint des neuen Requests vom gespeicherten Fingerprint ab, antwortet der Server mit 409 Conflict statt mit der zwischengespeicherten Antwort. Diese explizite Fehlermeldung macht den Client-Bug sofort sichtbar, statt ihn durch stillschweigend falsches Verhalten zu verschleiern.
7. Ablaufzeit und Aufräumen abgelaufener Einträge
Idempotenz-Schlüssel sollten nicht unbegrenzt gespeichert werden, eine typische Ablaufzeit liegt zwischen 24 Stunden und 7 Tagen, abhängig davon, wie lange realistische Retry-Versuche eines Clients erwartet werden. Nach Ablauf dieser Frist kann derselbe Schlüssel theoretisch wiederverwendet werden, in der Praxis generiert jeder Client aber ohnehin für jede neue logische Operation einen frischen Schlüssel.
Ein regelmäßiger Cron-Job oder ein Scheduled-Task sollte abgelaufene Einträge aus der Idempotenz-Tabelle entfernen, um unkontrolliertes Tabellenwachstum zu vermeiden. Bei Redis als Speicher übernimmt die eingebaute TTL-Funktion diese Aufgabe automatisch, ohne zusätzlichen Aufräum-Job.
8. Idempotenz bei nebenläufigen Requests
Der kritischste Punkt einer korrekten Idempotenz-Implementierung ist der Schutz vor Race Conditions: treffen zwei Requests mit demselben Schlüssel exakt gleichzeitig ein, dürfen niemals beide die Geschäftslogik ausführen. Der eindeutige Index auf der Schlüsselspalte aus dem Tabellenschema übernimmt hier die entscheidende Absicherung: der zweite INSERT-Versuch mit demselben Schlüssel schlägt auf Datenbankebene fehl, bevor die Geschäftslogik überhaupt startet.
In PHP fängt man diesen Datenbankfehler ab und behandelt ihn wie den Fall "Schlüssel existiert bereits im Zustand processing", der Request wird dann mit 409 Conflict beantwortet. Diese Absicherung auf Datenbankebene ist robuster als eine rein anwendungsseitige Prüfung, weil sie auch bei mehreren parallelen PHP-Prozessen oder Servern korrekt funktioniert, ohne verteilte Locks zu benötigen.
9. Ohne Idempotenz vs. mit Idempotenz-Keys im Vergleich
Die folgende Tabelle zeigt den Unterschied im Verhalten bei einem Netzwerkfehler nach erfolgreicher serverseitiger Verarbeitung.
| Szenario | Ohne Idempotenz-Key | Mit Idempotenz-Key |
|---|---|---|
| Client-Retry nach Timeout | Erzeugt zweite Zahlung/Bestellung | Liefert gespeicherte Antwort zurück |
| Zwei parallele Requests | Beide werden verarbeitet | Nur der erste wird verarbeitet |
| Versehentliche Key-Wiederverwendung | Nicht anwendbar | 409 Conflict durch Fingerprint-Prüfung |
| Implementierungsaufwand | Keiner | Moderat, Middleware plus Speicher |
| Geeignet für | Lesende, unkritische Operationen | Zahlungen, Bestellungen, Buchungen |
Für alle Endpunkte, bei denen ein doppelter POST-Request reale Konsequenzen hätte, ist der zusätzliche Implementierungsaufwand von Idempotenz-Keys gegenüber den möglichen Folgen doppelter Zahlungen oder Bestellungen praktisch immer gerechtfertigt.
Mironsoft
PHP-API-Zuverlässigkeit und Zahlungsverarbeitung
Doppelte Zahlungen und Bestellungen zuverlässig ausschließen?
Wir bauen Idempotenz-Middleware, Konflikterkennung und Race-Condition-Schutz für eure kritischen PHP-Endpunkte, produktionsreif und ohne verteilte Locks.
Risiko-Analyse
Kritische POST/PUT-Endpunkte auf Doppelverarbeitung prüfen
Idempotenz-Implementierung
Middleware, Speicher und Konflikterkennung produktionsreif aufsetzen
Race-Condition-Schutz
Datenbankbasierte Absicherung ohne verteilte Locks umsetzen
10. Zusammenfassung
Idempotenz-Schlüssel lösen ein reales, häufig unterschätztes Problem: ohne sie führt jeder Netzwerkfehler nach erfolgreicher, aber unbestätigter Verarbeitung zu einem Retry, der doppelte Zahlungen, Bestellungen oder Buchungen auslösen kann. Die Implementierung in PHP braucht eine eindeutige Speicherstruktur mit Fingerprint-Vergleich, eine Middleware, die vor der eigentlichen Geschäftslogik greift, und einen datenbankbasierten Schutz vor Race Conditions über einen eindeutigen Index statt anwendungsseitiger Locks.
Der Implementierungsaufwand für Idempotenz-Keys ist überschaubar, verglichen mit den Konsequenzen doppelter, kritischer Operationen. Für jeden PHP-Endpunkt, bei dem ein wiederholter Aufruf reale, nicht rückgängig machbare Folgen hätte, gehört ein Idempotenz-Schlüssel zur Grundausstattung, nicht zu den optionalen Erweiterungen.
Idempotenz-Schlüssel in PHP: Das Wichtigste auf einen Blick
Grundprinzip
Client generiert einmalig pro logischer Operation einen eindeutigen Schlüssel, sendet ihn bei jedem Retry identisch mit.
Speicherung
Schlüssel, Request-Fingerprint und gespeicherte Antwort in einer Tabelle mit eindeutigem Index.
Konflikterkennung
Abweichender Fingerprint bei gleichem Schlüssel führt zu 409 Conflict statt stillschweigend falscher Antwort.
Race-Condition-Schutz
Eindeutiger Datenbank-Index verhindert doppelte Verarbeitung bei exakt gleichzeitigen Requests.