Netzwerke sind unzuverlässig. Services fallen aus. Requests kommen doppelt an. Eine resiliente API erwartet diese Probleme und handhabt sie graceful – ohne Datenverlust, ohne doppelte Buchungen, ohne Kaskaden-Ausfälle.
Dieser Artikel zeigt, wie du Timeouts, Retries und Circuit Breaker richtig einsetzt und wie Idempotency Keys doppelte Requests unschädlich machen.
Zielbild
Nach diesem Artikel kannst du:
- Timeouts für Server und Downstream-Calls sinnvoll konfigurieren
- Retry-Strategien mit Backoff und Jitter implementieren
- Circuit Breaker für Downstream-Services einsetzen
- Idempotency Keys für sichere Retries designen
- Optimistic Locking mit ETags umsetzen
- Ein Consistency-Modell dokumentieren
Kernfragen
Bevor du weiterliest, versuche diese Fragen für dein Projekt zu beantworten:
- Welche Timeouts gelten? Request? Datenbank? Externe Services?
- Welche Requests dürfen geretried werden? Nur GET? Auch POST?
- Wie verhindert ihr doppelte Ausführung? Idempotency Keys?
- Was passiert bei Downstream-Ausfall? Retry? Fallback? Fail?
- Strong oder Eventual Consistency? Wo ist was akzeptabel?
Timeouts: Die erste Verteidigungslinie
Ohne Timeouts kann ein hängender Request das ganze System blockieren.
Timeout-Schichten
Timeout-Empfehlungen
| Komponente | Timeout | Begründung |
|---|---|---|
| HTTP Request (gesamt) | 30s | User-Erwartung |
| Datenbank-Query | 5s | Lange Queries sind problematisch |
| Externer API-Call | 10s | Fremde Services können langsam sein |
| Cache (Redis) | 1s | Muss schnell sein |
| DNS Lookup | 2s | Sollte gecacht sein |
Timeout-Hierarchie
Downstream-Timeouts müssen innerhalb des Request-Budgets liegen:
Regel: Jeder Downstream-Call hat ein eigenes Timeout, das kleiner ist als das verbleibende Budget. Bei seriellen Abhängigkeiten sollte die Summe der Maxima plus Buffer unter dem Request-Timeout liegen; bei parallelen Calls zählt das Maximum.
Implementierung (Timeouts)
import asyncio
import httpx
# HTTP Client mit Timeout
client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=5.0, # Verbindungsaufbau
read=10.0, # Response lesen
write=5.0, # Request senden
pool=2.0 # Warten auf Connection aus Pool
)
)
# Datenbank mit Timeout
async def db_query_with_timeout(query, params, timeout=5.0):
result = await asyncio.wait_for(
db.execute(query, params),
timeout=timeout
)
return result
Timeout-Fehler kommunizieren
HTTP/1.1 504 Gateway Timeout
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/downstream-timeout",
"title": "Gateway Timeout",
"status": 504,
"error_code": "DOWNSTREAM_TIMEOUT",
"detail": "A downstream service did not respond in time.",
"request_id": "req_abc123"
}
Retries: Fehler überleben
Transiente Fehler (Netzwerk-Glitches, temporäre Überlastung) lassen sich oft durch Retries beheben.
Wann Retries?
| Fehler | Retry? | Begründung |
|---|---|---|
| 5xx Server Error | Ja | Temporäres Problem |
| 429 Too Many Requests | Ja (Retry-After) | Warten hilft |
| 408 Request Timeout | Ja | Temporäres Problem |
| Connection Error | Ja | Netzwerk-Glitch |
| 4xx Client Error | Nein | Request ist falsch |
| 401/403 | Nein | Auth-Problem |
Welche Requests retrien?
| Methode | Idempotent | Retry sicher? |
|---|---|---|
| GET | Ja | Ja |
| HEAD | Ja | Ja |
| OPTIONS | Ja | Ja |
| PUT | Ja (per Definition) | Ja (wenn idempotent umgesetzt) |
| DELETE | Ja (per Definition) | Ja (Seiteneffekte beachten) |
| POST | Nein | Nur mit Idempotency Key |
| PATCH | Kommt drauf an | Nur wenn idempotent oder Key |
Exponential Backoff
Warte zwischen Retries exponentiell länger:
Retry 1: 100ms
Retry 2: 200ms
Retry 3: 400ms
Retry 4: 800ms
Retry 5: 1600ms (Cap)
Jitter hinzufügen
Ohne Jitter retrien alle Clients gleichzeitig → Thundering Herd.
import random
def calculate_backoff(attempt: int, base: float = 0.1, cap: float = 2.0) -> float:
"""Exponential Backoff with Full Jitter"""
exp_backoff = min(cap, base * (2 ** attempt))
return random.uniform(0, exp_backoff)
# Beispiel-Werte:
# Attempt 0: 0 - 0.1s
# Attempt 1: 0 - 0.2s
# Attempt 2: 0 - 0.4s
# Attempt 3: 0 - 0.8s
# Attempt 4: 0 - 1.6s
# Attempt 5: 0 - 2.0s (capped)
Retry-Implementierung
import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=0.1, max=2.0),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError))
)
async def call_external_service(url: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
return response.json()
Retry-After respektieren
Bei 429 oder 503 mit Retry-After Header:
import asyncio
import email.utils
from datetime import datetime, timezone
def parse_retry_after(value, default=60):
if not value:
return default
try:
return max(0, int(value))
except ValueError:
dt = email.utils.parsedate_to_datetime(value)
if not dt:
return default
return max(0, int((dt - datetime.now(timezone.utc)).total_seconds()))
async def call_with_retry_after(url: str) -> dict:
for _ in range(3):
response = await client.get(url)
if response.status_code in (429, 503):
await asyncio.sleep(parse_retry_after(response.headers.get("Retry-After")))
continue
response.raise_for_status()
return response.json()
raise RuntimeError("Max retries exceeded")
Circuit Breaker: Kaskaden verhindern
Wenn ein Downstream-Service ausfällt, sollte nicht jeder Request darauf warten und fehlschlagen.
Das Problem ohne Circuit Breaker
Circuit Breaker Pattern
Zustände
| Zustand | Verhalten |
|---|---|
| CLOSED | Requests werden durchgelassen |
| OPEN | Requests werden sofort abgelehnt (Fail Fast) |
| HALF-OPEN | Ein Test-Request wird durchgelassen |
Konfiguration
from circuitbreaker import circuit
@circuit(
failure_threshold=5, # Nach 5 Fehlern öffnen
recovery_timeout=30, # 30s warten vor HALF-OPEN
expected_exception=(TimeoutError,)
)
async def call_payment_service(order_id: str) -> dict:
return await payment_client.charge(order_id)
Typische Werte
| Parameter | Wert | Begründung |
|---|---|---|
| Failure Threshold | 5-10 | Nicht zu empfindlich |
| Recovery Timeout | 30-60s | Zeit zum Erholen |
| Half-Open Requests | 1-3 | Vorsichtig testen |
Fallback bei offenem Circuit
async def get_user_profile(user_id: str) -> dict:
try:
return await call_user_service(user_id)
except CircuitBreakerError:
# Fallback: Cached Data oder Minimal-Response
cached = await cache.get(f"user:{user_id}")
if cached:
return {**cached, "_cached": True}
return {"id": user_id, "_degraded": True}
Idempotency Keys: Sichere Retries für Writes
Nicht-idempotente Writes (meist POST) sind nicht idempotent – ein Retry kann doppelte Aktionen verursachen (doppelte Bestellung, doppelte Zahlung).
Das Problem (Idempotency Keys)
Die Lösung: Idempotency Key
POST /v1/orders HTTP/1.1
Idempotency-Key: ord_req_abc123
Content-Type: application/json
{"product_id": "prod_xyz", "quantity": 1}
Server speichert: Key → Response (pro Client/Endpoint scopen)
Bei erneutem Request mit gleichem Key: gespeicherte Response zurückgeben.
Idempotency-Key-Flow
Implementierung (Idempotency Key)
import hashlib
import json
from datetime import timedelta
IDEMPOTENCY_TTL = timedelta(hours=24)
async def handle_with_idempotency(
idempotency_key: str,
request_hash: str,
handler: Callable
) -> Response:
# cache_key sollte Client/Tenant + Endpoint + Methode enthalten
cache_key = f"idempotency:{idempotency_key}"
# 1. Prüfen ob Key existiert
cached = await redis.get(cache_key)
if cached:
cached_data = json.loads(cached)
# 2. Request-Hash vergleichen (gleicher Key, anderer Request?)
if cached_data["request_hash"] != request_hash:
raise ConflictError("Idempotency key already used with different request")
# 3. Status prüfen
if cached_data["status"] == "processing":
return Response(
status=409,
body={"error_code": "IDEMPOTENCY_IN_PROGRESS"},
headers={"Retry-After": "5"}
)
# 4. Cached Response zurückgeben
return Response(
status=cached_data["response_status"],
body=cached_data["response_body"],
headers={"X-Idempotent-Replayed": "true"}
)
# 5. Als "processing" markieren
reserved = await redis.set(
cache_key,
json.dumps({"status": "processing", "request_hash": request_hash}),
ex=IDEMPOTENCY_TTL,
nx=True
)
if not reserved:
cached = await redis.get(cache_key)
if not cached:
return Response(
status=409,
body={"error_code": "IDEMPOTENCY_IN_PROGRESS"},
headers={"Retry-After": "5"}
)
cached_data = json.loads(cached)
if cached_data["status"] == "processing":
return Response(
status=409,
body={"error_code": "IDEMPOTENCY_IN_PROGRESS"},
headers={"Retry-After": "5"}
)
return Response(
status=cached_data["response_status"],
body=cached_data["response_body"],
headers={"X-Idempotent-Replayed": "true"}
)
try:
# 6. Request verarbeiten
response = await handler()
# 7. Response speichern
await redis.set(
cache_key,
json.dumps({
"status": "completed",
"request_hash": request_hash,
"response_status": response.status,
"response_body": response.body
}),
ex=IDEMPOTENCY_TTL
)
return response
except Exception as e:
# 8. Bei Fehler: Response cachen, um Duplikate zu vermeiden
error_response = Response(
status=502,
body={"error_code": "UPSTREAM_FAILURE"},
headers={}
)
await redis.set(
cache_key,
json.dumps({
"status": "failed",
"request_hash": request_hash,
"response_status": error_response.status,
"response_body": error_response.body
}),
ex=IDEMPOTENCY_TTL
)
return error_response
Idempotency-Key-Regeln
| Regel | Erklärung |
|---|---|
| Eindeutigkeit | UUID oder Request-spezifischer Hash |
| Scope | Pro Client/Tenant + Endpoint + Methode |
| Client-generiert | Server generiert keine Keys |
| TTL | 24h typisch, dann Key vergessen |
| Request-Binding | Key + Request-Hash zusammen speichern |
| Für Writes | Vor allem POST, ggf. PATCH/DELETE mit Effekt |
Hinweis: Idempotency-Key und X-Idempotent-Replayed sind de-facto
Standards, aber nicht RFC-spezifiziert. Dokumentiere sie explizit.
Wann Idempotency Keys?
| Endpoint | Idempotency Key? | Begründung |
|---|---|---|
POST /orders |
Ja | Doppelte Bestellungen verhindern |
POST /payments |
Ja (kritisch!) | Doppelte Zahlungen verhindern |
POST /messages |
Optional | Doppelte Nachrichten OK? |
POST /search |
Nein | Idempotent von Natur aus |
PUT /users/{id} |
Nein | PUT ist idempotent |
Optimistic Locking (Playbook)
Verhindert Lost Updates bei konkurrierenden Änderungen.
Das Problem (Optimistic Locking)
Die Lösung: ETag / If-Match
GET /v1/orders/123 HTTP/1.1
HTTP/1.1 200 OK
ETag: "v1"
{"id": "123", "status": "pending"}
Update nur wenn ETag stimmt:
PUT /v1/orders/123 HTTP/1.1
If-Match: "v1"
{"status": "confirmed"}
Wenn jemand anderes inzwischen geändert hat:
HTTP/1.1 412 Precondition Failed
ETag: "v2"
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/precondition-failed",
"title": "Precondition Failed",
"status": 412,
"detail": "Resource was modified. Please reload and retry.",
"error_code": "RESOURCE_CONFLICT",
"request_id": "req_def456"
}
Wenn If-Match erforderlich ist, aber fehlt: 428 Precondition Required.
Für If-Match nur starke ETags verwenden (kein W/).
Implementierung (Optimistic Locking)
async def update_order(order_id: str, data: dict, if_match: str) -> Order:
order = await Order.get(order_id)
# ETag prüfen
current_etag = f'"{order.version}"'
if if_match != current_etag:
raise PreconditionFailedError(
"Resource was modified",
current_etag=current_etag
)
# Update mit Version-Check
updated = await Order.update(
order_id,
data,
where={"version": order.version} # Optimistic Lock
)
if not updated:
# Concurrent modification
raise PreconditionFailedError("Resource was modified")
return updated
ETag-Formate
| Format | Beispiel | Use Case |
|---|---|---|
| Version Number | "v1", "42" |
Einfach, gut lesbar |
| Timestamp | "1706522400" |
Automatisch |
| Hash | "a1b2c3d4" |
Content-basiert |
Consistency Models
Nicht jede Operation braucht Strong Consistency.
Strong Consistency
Nach einem Write sehen alle Reads den neuen Wert.
Use Cases: Finanztransaktionen, Bestellungen, Authentifizierung
Eventual Consistency
Nach einem Write wird der Wert irgendwann überall sichtbar.
Use Cases: Analytics, Caches, Feeds, Likes/Counters
Read-Your-Writes Consistency
User sieht mindestens seine eigenen Writes sofort.
Use Cases: User-Profile, Einstellungen, eigene Posts
Dokumentation
Dokumentiere pro Operation, welches Konsistenzmodell gilt und warum. Das verhindert Überraschungen bei Caches, Replikation und UI-Erwartungen.
Beispiel: Konsistenzmodell pro Operation:
| Operation | Konsistenz | Begründung |
|---|---|---|
| Order erstellen | Strong | Keine doppelten Bestellungen |
| Order lesen | Strong | Aktueller Status kritisch |
| Produkt-Liste | Eventual | Cache OK, 5 min Verzögerung |
| User-Profil | Read-Your-Writes | User sieht eigene Änderungen |
| Analytics | Eventual | Verzögerung akzeptabel |
Regeln & Anti-Patterns
Do
- Timeouts auf allen Ebenen (Request, DB, externe Services)
- Timeout-Budget pro Downstream-Call definieren
- Exponential Backoff mit Jitter
- Circuit Breaker für Downstream-Services
- Idempotency Keys für nicht-idempotente Writes
- Optimistic Locking für konkurrierende Updates
- Consistency Model dokumentieren
Don't
- Unbegrenzte Timeouts
- Retries ohne Backoff (Thundering Herd)
- Retries für nicht-idempotente Requests ohne Key
- Circuit Breaker mit zu niedrigem Threshold
- Idempotency Keys ohne TTL (Memory Leak)
- Pessimistic Locking in APIs (Deadlocks, schlechte Performance)
- Strong Consistency überall (Skalierungsprobleme)
Artefakt: Resilience-Playbook
# Resilience-Playbook
## Timeouts
| Komponente | Timeout | Retry | Circuit Breaker |
|-----------------------|---------|-------|-----------------|
| HTTP Request (gesamt) | 30s | - | - |
| PostgreSQL | 5s | Nein | Nein |
| Redis | 1s | 1x | Ja |
| Payment Service | 10s | 2x | Ja |
| Notification Service | 5s | 3x | Ja |
| External Partner API | 15s | 2x | Ja |
## Retry-Konfiguration
```yaml
retry:
max_attempts: 3
backoff:
initial: 100ms
max: 2s
multiplier: 2
jitter: true
retryable_errors:
- 5xx
- 429
- 408
- connection_error
- timeout
non_retryable_errors:
- 400
- 401
- 403
- 404
- 422
```
## Circuit Breaker
| Service | Failure Threshold | Recovery Timeout | Fallback |
|--------------|-------------------|------------------|-------------------------|
| Payment | 5 | 60s | Reject with retry-later |
| Notification | 10 | 30s | Queue for later |
| Analytics | 20 | 30s | Skip silently |
| Cache | 5 | 10s | Bypass to DB |
## Idempotency
### Pflicht-Endpoints
| Endpoint | Key-Format | TTL |
|-------------------|------------------|-----|
| `POST /orders` | `ord_req_{uuid}` | 24h |
| `POST /payments` | `pay_req_{uuid}` | 48h |
| `POST /refunds` | `ref_req_{uuid}` | 48h |
| `POST /transfers` | `txf_req_{uuid}` | 24h |
### Implementierung (Idempotency-Policy)
1. Client generiert UUID für jeden neuen Request
2. Client sendet `Idempotency-Key: {key}` Header
3. Bei Retry: gleichen Key verwenden
4. Server speichert Key → Response für TTL
5. Bei bekanntem Key: gespeicherte Response zurückgeben
### Response Headers
```http
# Neuer Request
X-Idempotent-Replayed: false
# Wiederholter Request
X-Idempotent-Replayed: true
```
## Optimistic Locking
### Endpoints mit ETag
- `PUT /orders/{id}`
- `PATCH /orders/{id}`
- `PUT /users/{id}`
- `DELETE /orders/{id}` (optional)
### Flow
1. `GET /orders/123` → Response mit `ETag: "v1"`
2. `PUT /orders/123` mit `If-Match: "v1"`
3. Wenn Version passt: `200 OK` mit neuem `ETag: "v2"`
4. Wenn Version nicht passt: `412 Precondition Failed`
## Consistency Model
| Bereich | Consistency | Max Delay | Begründung |
|-----------------|------------------|--------------------|---------------------|
| Orders | Strong | 0 | Geschäftskritisch |
| Payments | Strong | 0 | Finanziell kritisch |
| User Profile | Read-Your-Writes | 0 für eigene Daten | UX |
| Product Catalog | Eventual | 5 min | Cache erlaubt |
| Search Index | Eventual | 15 min | Nicht kritisch |
| Analytics | Eventual | 1 hour | Aggregiert |
## Incident Response
### Downstream-Ausfall
1. Circuit Breaker öffnet → Fail Fast
2. Alert an On-Call
3. Fallback aktivieren (wenn vorhanden)
4. Monitoring beobachten
5. Nach Recovery: Circuit Breaker schließt automatisch
### Erhöhte Latenz
1. Timeouts greifen → 504 an Client
2. Alert bei Latenz > P95 Baseline
3. Ursache identifizieren (DB? External Service?)
4. Ggf. Traffic reduzieren (Feature Flags)
### Idempotency-Store voll
1. Alert bei Redis Memory > 80%
2. TTL prüfen (zu lang?)
3. Alte Keys manuell löschen
4. Ggf. Redis-Cluster skalieren
Checkliste
Bevor du zum nächsten Artikel gehst, prüfe:
- [ ] Timeouts sind auf allen Ebenen konfiguriert
- [ ] Timeout-Budget pro Downstream-Call definiert
- [ ] Retry-Strategie mit Exponential Backoff + Jitter
- [ ] Circuit Breaker für externe Services
- [ ] Idempotency Keys für nicht-idempotente Writes
- [ ] Idempotency-Key-Store mit TTL
- [ ] Optimistic Locking für konkurrierende Updates
- [ ] ETag/If-Match für Update-Endpoints
- [ ] Consistency Model dokumentiert
- [ ] Resilience-Playbook existiert
- [ ] Fallback-Strategien definiert
Wie es weitergeht
Im nächsten Teil geht es um Caching: ETags, Cache-Control, Conditional Requests und wie du Caching effektiv nutzt, ohne Konsistenz-Probleme zu verursachen.
Alle Teile der Serie: Serie: API-Design