Idempotenz API Design
Idempotenz in API Design
Idempotente APIs können sicher mehrfach aufgerufen werden ohne unerwünschte Nebeneffekte. Lernen Sie Idempotenz richtig zu implementieren für robuste und fehlertolerante APIs.
Was ist Idempotenz?
┌─────────────────────────────────────────────────────────────┐
│ IDEMPOTENZ │
├─────────────────────────────────────────────────────────────┤
│ │
│ "Eine Operation ist idempotent, wenn sie mehrfach │
│ ausgeführt werden kann und das gleiche Ergebnis │
│ liefert wie eine einzige Ausführung." │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ f(x) = f(f(x)) = f(f(f(x))) = ... │ │
│ │ │ │
│ │ 1 Aufruf = 2 Aufrufe = n Aufrufe │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ BEISPIEL IDEMPOTENT: │
│ PUT /users/123 { "name": "John" } │
│ → Ausführung 1x: User heißt John │
│ → Ausführung 3x: User heißt immer noch John ✅ │
│ │
│ BEISPIEL NICHT IDEMPOTENT: │
│ POST /orders { "product": "Laptop" } │
│ → Ausführung 1x: 1 Bestellung │
│ → Ausführung 3x: 3 Bestellungen ❌ │
│ │
└─────────────────────────────────────────────────────────────┘
HTTP Methoden und Idempotenz
| Methode | Idempotent | Safe | Erklärung |
|---|---|---|---|
| GET | ✅ Ja | ✅ Ja | Liest nur, ändert nichts |
| HEAD | ✅ Ja | ✅ Ja | Wie GET ohne Body |
| PUT | ✅ Ja | ❌ Nein | Ersetzt komplette Ressource |
| DELETE | ✅ Ja | ❌ Nein | Ressource ist danach weg |
| POST | ❌ Nein | ❌ Nein | Erstellt neue Ressource |
| PATCH | ❌ Nein* | ❌ Nein | Teilweise Updates |
* PATCH kann idempotent implementiert werden, ist es aber nicht per Definition.
Warum ist Idempotenz wichtig?
SZENARIO: Netzwerk-Timeout bei Zahlung
┌─────────────────────────────────────────────────────────────┐
│ │
│ Client Server │
│ │ │ │
│ │── POST /payments ──────►│ │
│ │ { amount: 100 } │ │
│ │ │ ┌──────────────────────┐ │
│ │ │ │ Zahlung verarbeitet │ │
│ │ │ │ 100€ abgebucht │ │
│ │ │ └──────────────────────┘ │
│ │ │ │
│ │◄── TIMEOUT ────────────│ Response verloren! │
│ │ │ │
│ │ │ │
│ │── POST /payments ──────►│ Client versucht erneut │
│ │ { amount: 100 } │ │
│ │ │ ┌──────────────────────┐ │
│ │ │ │ NOCH MAL verarbeitet │ │
│ │ │ │ 100€ ERNEUT abgebucht│ │
│ │ │ └──────────────────────┘ │
│ │ │ │
│ │◄── 201 Created ────────│ │
│ │
│ RESULTAT: Kunde hat 200€ statt 100€ bezahlt! 💸 │
│ │
└─────────────────────────────────────────────────────────────┘
Idempotency Key Pattern
// Client sendet eindeutigen Key mit Request
POST /api/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"amount": 100,
"currency": "EUR",
"customer_id": 123
}
// Server speichert Key + Response
// Bei wiederholtem Request: Cached Response zurückgeben
// PHP Implementation
class IdempotencyMiddleware
{
public function handle(Request $request, Closure $next)
{
// Nur für POST Requests
if ($request->method() !== 'POST') {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key');
if (!$idempotencyKey) {
return $next($request); // Ohne Key normal verarbeiten
}
// Prüfen ob Request schon verarbeitet wurde
$cached = Cache::get("idempotency:{$idempotencyKey}");
if ($cached) {
// Gleicher Request? Cached Response zurückgeben
if ($this->requestMatches($request, $cached['request'])) {
return response()->json(
$cached['response'],
$cached['status']
)->header('Idempotent-Replayed', 'true');
}
// Anderer Request mit gleichem Key = Fehler
return response()->json([
'error' => 'Idempotency key already used for different request'
], 422);
}
// Request verarbeiten
$response = $next($request);
// Response cachen (24 Stunden)
Cache::put("idempotency:{$idempotencyKey}", [
'request' => $this->hashRequest($request),
'response' => $response->getData(),
'status' => $response->status()
], 86400);
return $response;
}
private function hashRequest(Request $request): string
{
return hash('sha256', json_encode([
'path' => $request->path(),
'body' => $request->all()
]));
}
private function requestMatches(Request $request, string $hash): bool
{
return $this->hashRequest($request) === $hash;
}
}
Idempotent POST mit Client-ID
// Alternative: Client generiert ID
POST /api/orders
Content-Type: application/json
{
"id": "ord_550e8400-e29b-41d4-a716-446655440000", // Client-generiert
"product_id": 123,
"quantity": 2
}
// Server: INSERT ... ON CONFLICT DO NOTHING
// oder: Prüfen ob ID existiert
// Response bei Wiederholung:
// 200 OK (existierende Bestellung)
// statt
// 201 Created (neue Bestellung)
// SQL mit UPSERT
INSERT INTO orders (id, product_id, quantity, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO NOTHING
RETURNING *;
// PHP Implementation
class OrderController
{
public function store(Request $request): JsonResponse
{
$orderId = $request->input('id');
// Prüfen ob Bestellung existiert
$existingOrder = Order::find($orderId);
if ($existingOrder) {
// Idempotent: Existierende Bestellung zurückgeben
return response()->json($existingOrder, 200)
->header('Idempotent-Replayed', 'true');
}
// Neue Bestellung erstellen
$order = Order::create([
'id' => $orderId,
'product_id' => $request->input('product_id'),
'quantity' => $request->input('quantity')
]);
return response()->json($order, 201);
}
}
Idempotentes DELETE
// DELETE ist per Definition idempotent // ABER: Was wenn Ressource nicht existiert? // Option 1: 404 Not Found DELETE /api/users/999 → 404 Not Found (nicht idempotent!) // Option 2: 204 No Content (auch wenn nicht existiert) DELETE /api/users/999 → 204 No Content (idempotent!) // Empfehlung: 204 No Content // "Die Ressource existiert nicht (mehr)" = Erfolgreich
// PHP Implementation
class UserController
{
public function destroy(int $id): Response
{
// Soft Delete oder Hard Delete
$deleted = User::where('id', $id)->delete();
// Immer 204, egal ob gelöscht oder nicht existiert
return response()->noContent(); // 204
// NICHT:
// if (!$deleted) return response()->json(['error' => 'Not found'], 404);
}
}
Idempotentes PATCH
// PATCH kann idempotent gemacht werden
// ❌ Nicht idempotent: Relative Änderung
PATCH /api/accounts/123
{ "balance_change": +100 }
1x ausführen: Balance = 1000 + 100 = 1100
2x ausführen: Balance = 1100 + 100 = 1200 ❌
// ✅ Idempotent: Absolute Änderung
PATCH /api/accounts/123
{ "balance": 1100 }
1x ausführen: Balance = 1100
2x ausführen: Balance = 1100 ✅
// Idempotentes Increment mit Version/ETag
PATCH /api/accounts/123
If-Match: "v42"
Content-Type: application/json
{ "balance": 1100 }
// Server prüft Version
// Wenn v42 nicht aktuell: 412 Precondition Failed
// Client muss neu laden und Änderung neu berechnen
Retry-Strategien
// Client-seitige Retry mit Idempotency Key
async function createPayment(data, maxRetries = 3) {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(data)
});
if (response.ok) {
return await response.json();
}
// Nicht retrybare Fehler
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
// Server-Fehler: Retry
} catch (error) {
if (attempt === maxRetries) throw error;
// Exponential Backoff
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
}
Best Practices
1. IDEMPOTENCY KEY • UUID/ULID verwenden • Client generiert Key • 24-48 Stunden cachen • Bei Konflikt: 409 oder 422 2. RESPONSE CACHING • Request Hash speichern • Gleicher Key + anderer Request = Fehler • Header "Idempotent-Replayed: true" 3. DESIGN • PUT statt POST wo möglich • Client-generierte IDs • Absolute statt relative Änderungen • Versioning/ETags für Updates 4. FEHLERBEHANDLUNG • Retryable vs Non-retryable Errors • Exponential Backoff • Idempotent auch bei Fehlern
💡 Zusammenfassung:
1. Idempotency-Key Header für POST Requests
2. Client-generierte IDs als Alternative
3. DELETE immer 204, auch wenn nicht existiert
4. Absolute Werte statt Deltas bei Updates
5. Response cachen für Replay-Requests
2. Client-generierte IDs als Alternative
3. DELETE immer 204, auch wenn nicht existiert
4. Absolute Werte statt Deltas bei Updates
5. Response cachen für Replay-Requests