post

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

Warum Resilienz kein Zufall ist: Timeout, Retry mit Backoff und Jitter, Circuit Breaker und Idempotenz als bewusst entworfene Muster gegen die Ausfallmodi verteilter Systeme.

≈ 10 Min. Lesezeit

Ich habe in genug Post-Mortems gesessen, um eine Regelmäßigkeit zu kennen: Der eigentliche Ausfall war selten der große, spektakuläre Crash. Es war ein Service, der ein bisschen langsamer antwortete als sonst. Ein Timeout, den niemand gesetzt hatte. Ein Client, der brav weiter Anfragen nachschob, während die Dependency schon am Boden lag. Und plötzlich reißt ein einzelner wackliger Baustein das halbe System mit.

Verteilte Aufrufe scheitern. Nicht als Ausnahme, sondern als Normalfall über die Zeit. 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. Das ist die These dieses Artikels. Ich gehe die vier Muster durch, die ich in jeder verteilten Architektur voraussetze – Timeout, Retry, Circuit Breaker, Idempotenz – zeige illustrativen Code in TypeScript und schließe mit den Fallstricken, die ich am häufigsten sehe. Der rote Faden knüpft direkt an das an, was ich in Fehler als Architektur in Node.js beschrieben habe: Fehler sind kein Rand des Programms, sondern ein Teil seines Entwurfs.

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 greifen ineinander. Ein Timeout ohne Retry gibt zu früh auf. Ein Retry ohne Idempotenz produziert Doppeleffekte. Ein Circuit Breaker ohne Timeout feuert nie, weil die Calls ewig hängen. Man kann sie einzeln erklären, aber man setzt sie zusammen ein.

Timeout: nie unendlich warten

Der teuerste Timeout ist der, den es nicht gibt. Ein Remote-Call ohne Zeitgrenze ist keine langsame Operation, sondern eine potenziell unendliche. 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:

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" – ist keine Resilienz, sondern 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:

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, ein Timeout – das sind transiente Fehler, bei denen ein zweiter Versuch Sinn ergibt. Ein 400 Bad Request oder ein 409 Conflict ist es nicht: Die Anfrage war falsch und wird beim zehnten Mal genauso falsch sein. Retry auf nicht-transiente Fehler verbrennt nur Zeit und Last.

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. Retry gehört auf genau eine Schicht, nicht auf jede. Jitter und Cap sind nicht optional. Und wer viele Clients koordiniert, ergänzt ein Retry-Budget oder ein clientseitiges Rate-Limiting per Token-Bucket – das ist der Ansatz hinter dem adaptive-Modus der AWS-SDKs und dem, was Well-Architected unter REL05-BP02 als Throttling beschreibt. Der Punkt bleibt: Retry löst ein Problem für den einzelnen Aufruf und schafft ein neues für das System, wenn man es unbedacht überall verteilt.

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:

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:

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 nicht. Wo man Operationen idempotent designen 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", braucht es einen expliziten Mechanismus: den 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 liefert bei Wiederholung dieselbe gespeicherte Antwort zurück, ohne den Seiteneffekt ein zweites Mal auszuführen.

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:

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 den passenden Rahmen, und wie man Last über mehrere Prozesse verteilt, habe ich in child_process und Skalierung beschrieben.

Fazit

Die drei Fehler, die ich am häufigsten sehe, sind immer dieselben. 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. Man hat den glücklichen Pfad gebaut und angenommen, dass die Fehlerfälle sich schon irgendwie von selbst benehmen. Tun sie nicht. Die vier Muster – Timeout, Retry, Circuit Breaker, Idempotenz – sind keine Bibliothek, die man importiert, sondern Entwurfsentscheidungen, die man bewusst trifft: für jeden Remote-Call einen Timeout, für jede Wiederholung Backoff mit Jitter und Grenze, für jede ausfallende Dependency einen Breaker, für jeden wiederholbaren Write einen Idempotency-Key. Wer das von Anfang an mitdenkt, baut Systeme, die unter Last nicht spektakulär crashen, sondern kontrolliert degradieren. Und das ist der einzige Zustand, in dem man um drei Uhr nachts nicht im Post-Mortem sitzt.

Weiterführende Quellen

Kommentare