Skip to content

How-to: Circuit Breaker

FT-Referenz: FT298 (NENE2-FT/circuitlog) — Circuit-Breaker-Muster: geschlossen/offen/halb_offen Drei-Zustands-Maschine, konfigurierbarer Fehlerschwellenwert, timeout-basierter automatischer Übergang zu half_open, 503 Service Unavailable bei offenem Circuit, schreibgeschützte isCallAllowed()-Prüfung, 15 Tests / 28 Assertions PASS.

Das Circuit-Breaker-Muster verhindert Kaskadenausfälle beim Aufruf externer Dienste. Statt langsame oder fehlgeschlagene Aufrufe zu stapeln, öffnet der Circuit und lehnt Aufrufe sofort ab, bis sich die Abhängigkeit erholt hat.

Drei Zustände

Closed ──(N aufeinanderfolgende Fehler)──▶ Open ──(Timeout abgelaufen)──▶ Half-Open
  ▲                                                                             │
  └────────────────────────────(Erfolg)───────────────────────────────────────┘
  Half-Open ──(Fehler)──▶ Open
ZustandVerhalten
ClosedNormal — Aufrufe werden durchgelassen. Fehleranzahl erhöht sich bei jedem Fehler.
OpenAufrufe werden sofort mit 503 abgelehnt. Öffnet für timeout_seconds nach failure_threshold aufeinanderfolgenden Fehlern.
Half-OpenEin einzelner Probeaufruf wird erlaubt. Erfolg → Closed (Reset). Fehler → erneut Open.

Schema

sql
CREATE TABLE IF NOT EXISTS circuits (
    id                INTEGER PRIMARY KEY AUTOINCREMENT,
    name              TEXT    NOT NULL UNIQUE,
    state             TEXT    NOT NULL DEFAULT 'closed',
    failure_count     INTEGER NOT NULL DEFAULT 0,
    failure_threshold INTEGER NOT NULL DEFAULT 5,
    open_until        TEXT,
    half_open_at      TEXT,
    last_failure_at   TEXT,
    updated_at        TEXT    NOT NULL
);

Der Circuit-Name ist in der Regel der Bezeichner des externen Dienstes (z.B. payment-gateway, email-svc). Mehrere unabhängige Circuits können koexistieren.

Ergebnisse aufzeichnen

php
// Nach einem erfolgreichen Aufruf des externen Dienstes:
$this->repo->recordSuccess($circuitName, $now);

// Nach einem fehlgeschlagenen Aufruf:
$this->repo->recordFailure($circuitName, $now, timeoutSeconds: 30);

recordFailure() entscheidet über den Übergang:

  • Wenn failure_count + 1 >= failure_threshold → Status auf open setzen, open_until = now + timeout berechnen.
  • Wenn noch unter dem Schwellenwert → failure_count erhöhen, closed bleiben.
  • Wenn im half_open-Status → jeder Fehler öffnet sofort wieder.

Prüfen, ob ein Aufruf erlaubt ist

php
$circuit = $this->repo->maybeTransitionToHalfOpen($name, $now);

if (!$circuit->isCallAllowed($now)) {
    // Sofort 503 zurückgeben — den externen Dienst nicht aufrufen
    return $problems->create($request, 'service-unavailable', 'Circuit is open.', 503);
}

// Aufruf versuchen...

maybeTransitionToHalfOpen() vor der isCallAllowed()-Prüfung bei jeder Anfrage aufrufen. Dies löst den Open → Half-Open-Übergang aus, sobald open_until abgelaufen ist, und lässt den Probeaufruf durch.

php
public function isCallAllowed(string $now): bool
{
    return match ($this->state) {
        CircuitState::Closed   => true,
        CircuitState::Open     => $now >= ($this->openUntil ?? ''),
        CircuitState::HalfOpen => true,
    };
}

Half-Open-Timing

Der Open → Half-Open-Übergang erfolgt lazy: Er findet beim nächsten Aufruf von maybeTransitionToHalfOpen() nach Ablauf von open_until statt. Dies ist beabsichtigt — es vermeidet Hintergrund-Timer und bindet Zustandsänderungen an eingehende Anfragen.

Fehlerschwellenwert und Timeout-Abstimmung

AbhängigkeitstypEmpfohlener SchwellenwertEmpfohlener Timeout
Datenbank (kritisch)3–510–30s
Externe API5–1030–60s
Nicht-kritischer Dienst10–2060–120s

Höhere Schwellenwerte reduzieren Fehlalarme (vorübergehende Störungen). Längere Timeouts geben Abhängigkeiten mehr Erholungszeit, verlängern aber die für den Kunden sichtbare Beeinträchtigung.

Mehrere Circuits pro Dienst

Distinct Circuit-Namen für distinct Fehlerdomänen verwenden:

payment-gateway/charge
payment-gateway/refund
email-svc/transactional
email-svc/marketing

Dies verhindert, dass ein Fehler im Refund-Endpunkt Charge-Versuche blockiert.

Antwort bei offenem Circuit

503 Service Unavailable mit einem Retry-After-Header zurückgeben, der auf open_until zeigt:

php
return $problems->create($request, 'service-unavailable', 'Circuit is open.', 503, null, [
    'open_until' => $circuit->openUntil,
]);

Clients und Load Balancer, die 503 respektieren, können das Routing zu dieser Instanz während des offenen Circuits einstellen.

Design-Entscheidungen

Warum DB-backed State statt In-Memory? In-Memory-State geht beim Neustart verloren und wird nicht über PHP-FPM-Worker hinweg geteilt. DB-State ist über alle Worker konsistent und übersteht Neustarts, auf Kosten einer zusätzlichen DB-Abfrage pro geschütztem Aufruf. Für hochfrequente Pfade Redis mit atomaren Increment-Operationen in Betracht ziehen.

Warum lazy Half-Open-Übergang? Proaktive Hintergrundübergänge erfordern einen Scheduler oder Daemon. Lazy Übergänge sind einfacher, aus Scheduler-Sicht zustandslos und für die meisten Web-APIs ausreichend, bei denen das Request-Volumen sicherstellt, dass die Prüfung zeitnah ausgeführt wird.

Warum setzt failure_count bei jedem Erfolg zurück? Dies ist "aufeinanderfolgende Fehler"-Semantik. Eine Alternative ist "Fehlerrate über ein gleitendes Fenster" (z.B. >50% Fehler in den letzten 60 Sekunden). Das gleitende Fenster ist genauer für Dienste mit niedrigem, aber stetigem Traffic; aufeinanderfolgende Fehler sind einfacher und ausreichend für Dienste, die entweder voll funktionsfähig oder ausgefallen sind.


Was Sie NICHT tun sollten

Anti-PatternRisiko
Kein UNIQUE(name)-ConstraintGleichzeitige Erstellungen produzieren mehrere Zeilen für denselben Circuit
Kein Timeout bei offenem CircuitCircuit bleibt nach Schwellenwertüberschreitung für immer offen
Kein half_open-StatusCircuit wechselt direkt offen → geschlossen; kein Probe-dann-Verify
200 zurückgeben, wenn Circuit offen istAufrufer glauben, der Aufruf war erfolgreich; nachgelagerte Fehler versteckt
Kein open_until in 503-AntwortAufrufer wiederholen sofort (Thundering Herd); Retry-Timing einbeziehen
String "true" als Erfolg akzeptierenJSON-Typverwirrung; strikt is_bool() verwenden
isCallAllowed() ohne vorheriges maybeTransitionToHalfOpen() prüfenOffener Circuit wird nie half_open; dauerhaft blockiert
Nur In-Memory-StateState geht beim Worker-Neustart verloren; kein Sharing über PHP-FPM-Worker

Veröffentlicht unter der MIT-Lizenz.