# Resilience-Muster: Timeout, Retry, Circuit Breaker, Idempotenz

URL: https://www.mikebild.dev/de/blog/resilience-muster-timeout-retry-circuit-breaker/

Ein verteiltes System fällt selten wegen des spektakulären Crashs um. Es fällt um, weil ein Service noch antwortet – nur ein bisschen langsamer als sonst. Dazu ein Aufruf, dem nie jemand eine Zeitgrenze verpasst hat. Ein Client, der brav weiter Anfragen nachschiebt, während die Dependency längst am Boden liegt. So reißt ein einzelner wackliger Baustein das halbe System mit, ohne dass irgendwo ein Prozess abstürzt.

Verteilte Aufrufe scheitern. Über einen längeren Zeitraum betrachtet gehört das zum Normalbetrieb. Das Netzwerk ist mal langsam, ein Knoten wird neu gestartet, eine Datenbank läuft an ihr Limit. Die Frage ist nie, ob ein Remote-Call fehlschlägt, sondern was passiert, wenn er es tut. Und genau hier entscheidet sich, ob ein System resilient ist oder nur so lange stabil aussieht, bis es das erste Mal ernst wird.

Resilienz wird entworfen, nicht gehofft. Vier Muster prüfe ich bei verteilten Aufrufen: Timeout, Retry, Circuit Breaker und Idempotenz. Nicht jeder Call braucht alle vier; die Wahl hängt von Fehlerbild, Kosten und Semantik der Operation ab. Der TypeScript-Code weiter unten ist illustrativ – es geht um die Mechanik dahinter, nicht um eine produktionsfertige Bibliothek.

Die Muster unterscheiden sich vor allem durch den Fehlerzustand, auf den sie reagieren. Ihre Namen allein sagen noch nicht, ob sie an einer Grenze sinnvoll sind.

::::comparison{title="Resilience-Muster nach Fehlerfrage" role="primary" preferredPattern="comparison-matrix"}
:::side{label="Timeout"}
- Frage · Wie lange darf ein einzelner Versuch dauern?
- Wirkung · begrenzt blockierte Ressourcen
:::
:::side{label="Retry"}
- Frage · Ist der Fehler vorübergehend und der Versuch sicher wiederholbar?
- Wirkung · wiederholt mit begrenztem Backoff und Jitter
:::
:::side{label="Circuit Breaker"}
- Frage · Soll ein erkennbar gestörtes Ziel weiter belastet werden?
- Wirkung · unterbricht Aufrufe und prüft später kontrolliert erneut
:::
:::side{label="Bulkhead"}
- Frage · Wie bleibt ein Ausfall auf einen Teil des Systems begrenzt?
- Wirkung · trennt Ressourcen und Konkurrenzbereiche
:::
:::side{label="Dead Letter Queue"}
- Frage · Was geschieht mit dauerhaft nicht verarbeitbaren Nachrichten?
- Wirkung · bewahrt Fehlerfälle für Prüfung und gezielte Wiederaufnahme
:::
::::

Erst die Fehlerfrage macht sichtbar, welche Muster sich ergänzen und welche nur zusätzlichen Verkehr oder versteckte Warteschlangen erzeugen würden.

## Die Muster im Überblick

Bevor ich in den Code gehe, ein kurzer Überblick. Vier Muster, jedes mit einer klaren Zuständigkeit:

- Ein Timeout begrenzt, wie lange ein einzelner Aufruf warten darf. Ohne Timeout wartet ein Call im Zweifel unendlich – und blockiert dabei einen Thread, eine Connection, einen Slot im Pool.
- Ein Retry wiederholt einen fehlgeschlagenen Aufruf – aber nur bei transienten Fehlern, nur mit Exponential Backoff und Jitter, und nur mit begrenzter Versuchszahl.
- Ein Circuit Breaker stoppt weitere Aufrufe an eine erkennbar ausgefallene Dependency, damit man sie nicht endlos weiter hämmert.
- Idempotenz macht Retries überhaupt erst sicher, indem ein mehrfach gesendeter Aufruf denselben Effekt hat wie ein einziger.

Diese vier können ineinandergreifen, sind aber kein untrennbares Paket. Ein Timeout darf bewusst ohne Retry schnell scheitern. Ein Retry auf eine nicht idempotente Operation kann Doppeleffekte produzieren. Ein Circuit Breaker braucht ein verwertbares Fehlersignal, das häufig aus einem Timeout stammt. Zusammengesetzt werden nur die Muster, die zum jeweiligen Aufruf passen.

## Timeout: nie unendlich warten

Der teuerste Timeout ist der, den es nicht gibt. Ein Remote-Call ohne Zeitgrenze ist nicht langsam, er ist potenziell unendlich. Und Unendlichkeit ist ein Ausfallmodus: Threads stauen sich, Connection-Pools laufen leer, und ein Service, der eigentlich nur auf einen langsamen Downstream wartet, wird selbst unerreichbar.

Wichtig ist die Unterscheidung zwischen Connection-Timeout (wie lange darf der Verbindungsaufbau dauern) und Request-Timeout (wie lange darf die gesamte Antwort dauern). Beide gehören gesetzt. Und beide gehören zum jeweiligen Aufruf, nicht global auf einen Wert genagelt – ein Health-Check hat eine andere Toleranz als ein Report-Export.

In der Praxis kapsle ich das in einen kleinen Wrapper. `Promise.race` gegen einen Timer, plus ein `AbortController`, damit die unterliegende Operation auch wirklich abgebrochen wird und nicht im Hintergrund weiterläuft:

```ts
export async function withTimeout<T>(
  fn: (signal: AbortSignal) => Promise<T>,
  timeoutMs: number,
): Promise<T> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fn(controller.signal);
  } finally {
    clearTimeout(timer);
  }
}

// usage: the signal is forwarded to fetch so the socket is released
const result = await withTimeout(
  (signal) => fetch("https://payments.internal/charge", { signal }),
  2_000,
);
```

Der `AbortSignal` ist der Teil, den man leicht vergisst. Ein `Promise.race`, das nur das Warten abbricht, aber die HTTP-Verbindung offen lässt, hat das Problem nur verschoben: Der Socket bleibt belegt. Erst das durchgereichte Signal gibt die Ressource frei.

Eine subtile Wechselwirkung, auf die Marc Brooker in der AWS Builders' Library hinweist: Ein zu niedrig gewählter Timeout kann selbst zum Auslöser für Retry-Storms werden. Wenn die reguläre Latenz gelegentlich über der Timeout-Grenze liegt, interpretiert der Client gesunde Antworten als Fehler und wiederholt – und erzeugt damit genau die Last, die den Server dann wirklich umkippt. Timeouts setzt man an der realen Latenzverteilung aus, nicht am Wunschdenken.

## Retry: Backoff und Jitter, aber mit Verstand

Retry ist das Muster, das am schnellsten nach hinten losgeht. Die naive Variante – "bei Fehler sofort nochmal, und nochmal, und nochmal" – hat mit Resilienz nichts zu tun. Sie ist eine Lastwaffe gegen den eigenen Downstream. Ein Server, der gerade unter Druck steht, bekommt durch synchrone Wiederholungen aller Clients erst recht den Rest.

Drei Zutaten machen Retry brauchbar. **Exponential Backoff**: Die Wartezeit wächst mit jedem Versuch (`base * 2^attempt`), damit ein überlasteter Service Luft bekommt. Ein **Cap**: Die Wartezeit läuft nicht ins Unendliche, sondern gegen eine Obergrenze. Und **Jitter**: eine Zufallskomponente, die verhindert, dass alle Clients ihre Retries auf dieselbe Millisekunde ausrichten und synchronisierte Lastspitzen erzeugen.

Für den Jitter gibt es mehrere Varianten – Marc Brooker hat sie 2015 im AWS Architecture Blog gegeneinander gestellt: Full Jitter, Equal Jitter, Decorrelated Jitter. Die praktische Empfehlung aus dieser Analyse ist meist Full Jitter, weil es die Wiederholungen am gleichmäßigsten über das Zeitfenster streut. Die Formel ist angenehm schlicht:

```ts
type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  capMs: number;
  isRetryable: (error: unknown) => boolean;
};

export async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  opts: RetryOptions,
): Promise<T> {
  let lastError: unknown;

  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (!opts.isRetryable(error) || attempt === opts.maxAttempts - 1) {
        throw error;
      }

      // full jitter: random point in the exponentially growing window
      const window = Math.min(opts.capMs, opts.baseDelayMs * 2 ** attempt);
      const delay = Math.random() * window;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }

  throw lastError;
}
```

Der `isRetryable`-Callback ist bewusst nach außen gezogen. Nicht jeder Fehler ist wiederholbar. Ein `503 Service Unavailable`, ein Verbindungsabbruch oder ein Timeout kann transient sein. Ein unveränderter `400 Bad Request` bleibt dagegen falsch. Auch `409 Conflict` ist kein pauschales Nein: Nach erneutem Lesen, neuer Versionsnummer oder angepasster Operation kann ein weiterer Versuch sinnvoll sein; die identische Anfrage blind zu wiederholen meist nicht. Retry-Entscheidungen brauchen deshalb Fehlercode, Operation und verbleibendes Zeitbudget.

Die AWS Well-Architected-Guidance bringt das unter REL05-BP03 ("Control and limit retry calls") auf den Punkt: Backoff, Jitter und eine maximale Versuchszahl gehören zusammen. Die AWS-SDKs setzen genau das in ihren Retry-Modi `standard` und `adaptive` schon eingebaut um – wer auf dieser Ebene arbeitet, sollte nicht darüber noch eine zweite eigene Retry-Schleife legen. Womit wir beim gefährlichsten Fallstrick wären.

## Der Retry-Storm

Retries stapeln sich. Der Client wiederholt, das SDK darunter wiederholt, das API-Gateway davor wiederholt. Drei Schichten mit je drei Versuchen sind nicht drei, sondern bis zu siebenundzwanzig Aufrufe für eine einzige Nutzeraktion. Wenn dann noch der Jitter fehlt und alle Clients gleichzeitig loslegen, entsteht ein Retry-Storm: multiplikative Last, die exakt in dem Moment auf den Server trifft, in dem er sie am wenigsten verträgt.

Das Gegenmittel ist Disziplin, nicht mehr Technik. Für jede Aufrufkette sollte klar sein, welche Schicht den Retry besitzt und welches gemeinsame Zeit- und Versuchsbudget gilt. Niedrige Schichten wie ein SDK dürfen eigene Wiederholungen haben, wenn die darüberliegenden Schichten sie kennen und nicht unkoordiniert vervielfachen. Jitter und Cap gehören an die zuständige Stelle. Wer viele Clients koordiniert, ergänzt ein Retry-Budget oder clientseitiges Rate-Limiting per Token-Bucket.

## Circuit Breaker: aufhören zu hämmern

Retry mit Backoff federt kurze Störungen ab. Aber was, wenn eine Dependency nicht kurz hustet, sondern richtig ausfällt – für Minuten? Dann ist jeder weitere Versuch verschwendete Last, verschwendete Latenz und verschwendete Ressource. Genau hier setzt der Circuit Breaker an, ein Muster, das Michael Nygard in "Release It!" geprägt hat.

Die Idee stammt aus der Elektrotechnik: Ein Sicherungsschalter unterbricht den Stromkreis, bevor die Leitung durchbrennt. Übertragen auf Software heißt das – wenn eine Dependency erkennbar am Boden liegt, schaltet der Breaker auf und lässt Aufrufe sofort scheitern (Fail Fast), statt sie in ein totes Timeout laufen zu lassen. Der Breaker kennt drei Zustände:

```mermaid
stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN: Fehler >= Schwelle
    OPEN --> HALF_OPEN: Cooldown abgelaufen
    HALF_OPEN --> CLOSED: Probe erfolgreich
    HALF_OPEN --> OPEN: Probe scheitert
```

Im Zustand **CLOSED** läuft alles normal, der Breaker zählt nur die Fehler mit. Überschreitet die Fehlerzahl eine Schwelle, springt er auf **OPEN**: Aufrufe werden nicht mehr durchgelassen, sondern scheitern sofort – der Client bekommt schnell eine Antwort oder einen Fallback statt einer hängenden Verbindung. Nach einem Cooldown geht der Breaker in **HALF_OPEN** und lässt eine einzelne Probeanfrage durch. Klappt sie, schließt er wieder (CLOSED). Scheitert sie, öffnet er erneut und wartet den nächsten Cooldown ab.

Als kleine Zustandsmaschine ist das überschaubar:

```ts
type BreakerState = "CLOSED" | "OPEN" | "HALF_OPEN";

export class CircuitBreaker {
  private state: BreakerState = "CLOSED";
  private failureCount = 0;
  private openedAt = 0;

  constructor(
    private readonly failureThreshold: number,
    private readonly cooldownMs: number,
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === "OPEN") {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new Error("circuit is open");
      }
      this.state = "HALF_OPEN";
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    this.failureCount = 0;
    this.state = "CLOSED";
  }

  private onFailure(): void {
    this.failureCount++;
    if (this.state === "HALF_OPEN" || this.failureCount >= this.failureThreshold) {
      this.state = "OPEN";
      this.openedAt = Date.now();
    }
  }
}
```

AWS ordnet den Circuit Breaker in Well-Architected unter REL05-BP01 ein – "graceful degradation", also das Verwandeln einer harten Abhängigkeit in eine weiche. Statt dass ein ausgefallener Empfehlungsdienst die ganze Produktseite mitreißt, liefert der Breaker im OPEN-Zustand einen Fallback (etwa eine statische Liste) und die Seite bleibt bedienbar. Das ist der eigentliche Gewinn: nicht die perfekte Antwort, sondern eine akzeptable, die das Gesamtsystem am Leben hält.

In Produktion muss man das nicht selbst schreiben – etablierte Bibliotheken wie `opossum` für Node.js, Polly für .NET oder resilience4j für Java bringen das mit, inklusive Metriken und Half-Open-Sampling. Der Code oben ist illustrativ, damit die Zustandslogik sichtbar bleibt.

## Idempotenz: was Retries erst sicher macht

Jetzt schließt sich der Kreis zum Retry. Ein Retry ist nur dann harmlos, wenn die wiederholte Operation denselben Effekt hat wie eine einzelne. Genau das ist Idempotenz. Bei einem Lesezugriff ist das trivial – zweimal `GET` schadet nicht. Bei einem Write wird es ernst: Wenn der erste Versuch einer Zahlung eigentlich durchging, die Antwort aber im Timeout verloren ging, dann bucht der Retry ein zweites Mal ab. Der Kunde zahlt doppelt.

Die HTTP-Semantik gibt hier einen Rahmen vor: `GET`, `PUT` und `DELETE` gelten als idempotent, `POST` grundsätzlich nicht. Idempotent bedeutet denselben beabsichtigten Effekt bei Wiederholung; Statuscode oder Antwort müssen dabei nicht identisch sein. Wo man Operationen entsprechend entwerfen kann – ein `PUT` auf eine natürliche ID statt eines `POST`, das jedes Mal eine neue Ressource erzeugt –, sollte man das tun. Wo das nicht geht, etwa bei „charge“ oder „send“, hilft ein expliziter **Idempotency-Key**. Der Client generiert pro fachlicher Operation einen eindeutigen Schlüssel und schickt ihn mit; der Server merkt sich Schlüssel plus Ergebnis und verhindert damit den zweiten Seiteneffekt.

Das Muster ist seit Jahren etabliert – Stripe etwa nutzt einen `Idempotency-Key`-Header. Ein IETF-Standard war zum damaligen Zeitpunkt lediglich ein Internet-Draft (`draft-ietf-httpapi-idempotency-key-header`), aber das Muster selbst funktioniert unabhängig davon. Serverseitig sieht der Kern so aus:

```ts
type Result = { status: string; id: string };

interface IdempotencyStore {
  get(key: string): Promise<Result | undefined>;
  set(key: string, value: Result): Promise<void>;
}

export async function handleRequest(
  key: string,
  op: () => Promise<Result>,
  store: IdempotencyStore,
): Promise<Result> {
  const existing = await store.get(key);
  if (existing) {
    // key already processed: return the stored result, no second side effect
    return existing;
  }

  const result = await op();
  await store.set(key, result);
  return result;
}
```

Zwei Details, die in echt zählen. Erstens braucht der Store einen Ablauf (TTL) – Idempotency-Keys ewig aufzuheben ist weder nötig noch billig. Zweitens gibt es ein Nebenläufigkeitsproblem: Wenn zwei Retries fast gleichzeitig ankommen, kann der zweite den `get` abschließen, bevor der erste seinen `set` schreibt. In Produktion löst man das mit einer atomaren Reservierung des Keys (etwa einem bedingten Insert), nicht mit dem naiven Get-dann-Set oben. Der Code zeigt das Prinzip, nicht die produktionsfertige Sperre.

## Bulkhead und DLQ, kurz

Zwei Muster runden das Bild ab, ohne dass ich sie hier ausbreite. Das **Bulkhead** – benannt nach den Schotten im Schiffsrumpf – isoliert Ressourcen, damit ein überlasteter Pfad nicht das ganze System flutet. Getrennte Connection-Pools oder Thread-Pools pro Downstream sorgen dafür, dass eine hängende Dependency nur ihren eigenen Pool erschöpft und nicht alle anderen Aufrufe mit in den Abgrund zieht. Auch das stammt aus "Release It!".

Und die **Dead-Letter-Queue** (DLQ) ist das Auffangbecken für den Fall, dass alle Retries erschöpft sind. Statt eine nicht verarbeitbare Nachricht endlos im Kreis zu drehen oder still zu verlieren, landet sie in einer separaten Queue – zur Inspektion, für einen manuellen Eingriff oder eine spätere Wiederholung. In AWS ist das ein Standardbaustein von SQS und SNS. Wer die Verkettung solcher Schritte sauber orchestrieren will, findet in [Serverless-Workflows mit Step Functions](https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/) den passenden Rahmen, und wie man Last über mehrere Prozesse verteilt, habe ich in [child_process und Skalierung](https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/) beschrieben.

## Fazit

Drei Fehler tauchen in Post-Mortems immer wieder auf. Kein Timeout – ein Call, der unendlich warten darf und dabei Ressourcen bindet. Retry ohne Jitter und Cap – der freundlich gemeinte Wiederholungsversuch, der zum Retry-Storm wird und den schwachen Server endgültig erledigt. Und nicht-idempotente Writes, die wiederholt werden – die Doppelbuchung, die niemand kommen sah.

Alle drei haben eine gemeinsame Wurzel: Resilienz wurde gehofft, nicht entworfen. Die vier Muster – Timeout, Retry, Circuit Breaker, Idempotenz – sind keine Bibliothek, die man importiert, sondern Entscheidungen pro Aufruf. Remote-Calls brauchen eine begründete Zeitgrenze. Wiederholungen brauchen Budget, Backoff und Jitter. Ein Breaker lohnt sich bei Abhängigkeiten, deren anhaltender Ausfall sonst weiter Last und Latenz erzeugt. Ein Idempotency-Key gehört zu Operationen, bei denen Wiederholung möglich und ein doppelter Effekt schädlich ist. So kann ein System unter Last kontrolliert degradieren, statt seinen Ausfall zu vervielfachen.

## Weiterführende Quellen

- [AWS Well-Architected, REL05-BP03 – Control and limit retry calls](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/rel_mitigate_interaction_failure_limit_retries.html)
- [AWS Well-Architected, REL05-BP01 – Graceful degradation (Circuit Breaker)](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/rel_mitigate_interaction_failure_graceful_degradation.html)
- [AWS Well-Architected, REL05-BP02 – Throttle requests (Token Bucket)](https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_throttle_requests.html)
- [AWS Builders' Library – Timeouts, retries, and backoff with jitter (Marc Brooker)](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/)
- [AWS Architecture Blog – Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
- [Michael Nygard – Release It! (2. Auflage, 2018)](https://pragprog.com/titles/mnee2/release-it-second-edition/)
