TS
Thomas Schmitz

Freiberuflicher IT-Berater, Softwareentwickler & DevOps. Praxisnahe Artikel zu API-Design, Architektur und Cloud-Workflows.

Serie: API-Design · Teil 14 von 22

API-Design Teil 14: Resilience & Idempotency

Timeouts, Retries, Circuit Breaker und Idempotency Keys – wie du deine API robust gegen Fehler machst.

Praxisnah Checkliste

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:

  1. Welche Timeouts gelten? Request? Datenbank? Externe Services?
  2. Welche Requests dürfen geretried werden? Nur GET? Auch POST?
  3. Wie verhindert ihr doppelte Ausführung? Idempotency Keys?
  4. Was passiert bei Downstream-Ausfall? Retry? Fallback? Fail?
  5. 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

Client Request
Timeout: 30s

API Server
Request Processing: 25s

Database
Timeout: 5s

External API
Timeout: 10s

Cache
Timeout: 1s

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:

Request Timeout: 30s

DB Timeout: 5s

External API: 10s

Buffer: 15s
(für Processing + Parallelität)

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

Service A → Service B (down)

Alle Requests warten auf Timeout

Thread Pool erschöpft

Service A wird langsam

Services, die A aufrufen, werden langsam

Kaskaden-Ausfall

Circuit Breaker Pattern

Failures > Threshold

Timeout expired

Success

Failure

CLOSED
(normal)

OPEN
(fail fast)

HALF-OPEN
(probe)

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)

ServerClientServerClientTimeout!POST /ordersOrder erstelltResponse (verloren)POST /orders (Retry)Zweite Order erstellt!

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

ServerClientServerClientRequest 1Request 2 (Retry)POST + KeyKey existiert? NeinRequest verarbeitenResponse + Key speichernResponsePOST + KeyKey existiert? JaGespeicherte Response ladenCached Response

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)

DBAPIClient BClient ADBAPIClient BClient AGET /orders/123Read order (version 1)status=pending, version=1status=pending, version=1GET /orders/123Read order (version 1)status=pending, version=1status=pending, version=1PUT /orders/123 (confirmed)Update order (no version check)OKOKPUT /orders/123 (cancelled)Update order (no version check)OK (überschreibt A)OK

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.

StoreClientStoreClientWrite X=1AcknowledgedRead XGarantiert X=1

Use Cases: Finanztransaktionen, Bestellungen, Authentifizierung

Eventual Consistency

Nach einem Write wird der Wert irgendwann überall sichtbar.

StoreClientStoreClientZeit vergehtWrite X=1AcknowledgedRead XVielleicht X=1, vielleicht alter WertRead XGarantiert X=1

Use Cases: Analytics, Caches, Feeds, Likes/Counters

Read-Your-Writes Consistency

User sieht mindestens seine eigenen Writes sofort.

StoreUser BUser AStoreUser BUser AWrite X=1Read XGarantiert X=1Read XVielleicht alter Wert

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

Mehr Beiträge aus dem Blog.