Das Circuit-Breaker-Muster für widerstandsfähige Dienste
Wenn ein nachgelagerter Dienst zu scheitern beginnt, ist die naive Reaktion – jede eingehende Anfrage weiter zu wiederholen – genau der falsche Zug. Jede Wiederholung ist eine weitere Anfrage, die sich auf einen ohnehin angeschlagenen Dienst legt, und jeder Aufrufer, der auf ein Timeout wartet, ist ein Thread oder eine Verbindung, die dein eigener Dienst für nichts anderes nutzen kann. Das Circuit-Breaker-Muster gibt es, um das zu stoppen: Nach genug Fehlschlägen sendet es eine Weile gar keine Anfragen mehr und scheitert schnell statt langsam.
Die drei Zustände
Ein Circuit Breaker ist eine Zustandsmaschine mit drei Zuständen, benannt nach der elektrischen Analogie:
- Closed (geschlossen) – Normalbetrieb. Anfragen gehen an den nachgelagerten Dienst durch. Fehler werden gezählt.
- Open (offen) – die Fehlerschwelle wurde überschritten. Anfragen scheitern sofort, ohne den Aufruf überhaupt zu versuchen, für eine Abkühlzeit.
- Half-open (halb offen) – nach der Abkühlzeit werden eine (oder wenige) Testanfragen durchgelassen. Sind sie erfolgreich, schließt der Breaker wieder. Scheitern sie, öffnet er erneut und die Abkühlzeit beginnt von vorn.
CLOSED --(too many failures)--> OPEN --(cooldown elapses)--> HALF_OPEN
^ |
|______________________(test request succeeds)_________________|
(test request fails) -----> OPEN
Die entscheidende Einsicht liegt im Zustand „open": Statt dass jeder Aufrufer einzeln ein Timeout gegen einen ausgefallenen Dienst abwartet, scheitert der Breaker sofort und billig, gibt auf deiner Seite Ressourcen frei und lässt dem nachgelagerten Dienst Raum zur Erholung, ohne dass sich weitere Last auftürmt.
Eine minimale Implementierung
class CircuitBreaker {
constructor(fn, { failureThreshold = 5, cooldownMs = 30_000 } = {}) {
this.fn = fn;
this.failureThreshold = failureThreshold;
this.cooldownMs = cooldownMs;
this.state = 'CLOSED';
this.failureCount = 0;
this.openedAt = null;
}
async call(...args) {
if (this.state === 'OPEN') {
if (Date.now() - this.openedAt >= this.cooldownMs) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit is open — failing fast');
}
}
try {
const result = await this.fn(...args);
this.#onSuccess();
return result;
} catch (err) {
this.#onFailure();
throw err;
}
}
#onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
#onFailure() {
this.failureCount++;
if (this.state === 'HALF_OPEN' || this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
this.openedAt = Date.now();
}
}
}
Verwendung – wickle jede Funktion ein, die einen riskanten Aufruf macht:
const breaker = new CircuitBreaker(
(orderId) => fetch(`https://payments.internal/charge/${orderId}`).then((r) => r.json()),
{ failureThreshold: 5, cooldownMs: 30_000 },
);
try {
const result = await breaker.call(order.id);
} catch (err) {
// Either the payment call actually failed, or the breaker is open —
// either way, don't retry synchronously in the same request.
return res.status(503).json({ error: 'Payment service unavailable, try again shortly' });
}
Beachte, dass ein einzelner fehlgeschlagener Aufruf in HALF_OPEN den Breaker sofort wieder öffnet, statt erneut die volle Schwelle zu verlangen – der ganze Sinn der Half-open-Probe ist Vorsicht, daher sollte ein schlechtes Testergebnis genügen, um zu schließen, dass der Dienst noch nicht bereit ist.
Circuit Breaker vs. Retry vs. Timeout
Diese drei werden oft verwechselt, weil alle auf „der nachgelagerte Aufruf funktioniert nicht" reagieren, aber sie lösen verschiedene Probleme:
- Timeout begrenzt, wie lange man auf einen einzelnen Aufruf wartet, bevor man aufgibt.
- Retry wiederholt einen einzelnen fehlgeschlagenen Aufruf, meist mit Backoff, in der Annahme, der Fehler sei vorübergehend gewesen.
- Circuit Breaker verfolgt Fehler über Aufrufe hinweg über die Zeit und hört ganz auf, Aufrufe zu versuchen, sobald ein Dienst systemisch kaputt wirkt statt nur vorübergehend vom Pech verfolgt.
Sie lassen sich kombinieren: Ein Aufruf wird typischerweise in ein Timeout gewickelt, bei Fehlschlag begrenzt oft wiederholt, und die Retry-Logik selbst sitzt hinter einem Circuit Breaker, damit ein systemisch ausgefallener Dienst nicht mit (Timeout + Wiederholungen) × jeder eingehenden Anfrage bombardiert wird.
Was die Fehlerschwelle zählen sollte
Rohe Fehler über unbegrenzte Zeit zu zählen ist meist falsch – fünf Fehler bei fünf Anfragen in dieser Minute sind ein echtes Problem; fünf Fehler bei zehntausend Anfragen über eine Woche sind Rauschen. Ein gleitendes Fenster (Fehler in den letzten N Anfragen oder den letzten N Sekunden zählen) ist die praktische Wahl:
class SlidingWindowBreaker {
constructor(fn, { failureRateThreshold = 0.5, windowSize = 20, cooldownMs = 30_000 } = {}) {
this.fn = fn;
this.failureRateThreshold = failureRateThreshold;
this.windowSize = windowSize;
this.cooldownMs = cooldownMs;
this.state = 'CLOSED';
this.results = []; // rolling window of true/false (success/failure)
this.openedAt = null;
}
#recordResult(success) {
this.results.push(success);
if (this.results.length > this.windowSize) this.results.shift();
}
#failureRate() {
if (this.results.length < this.windowSize) return 0; // not enough data yet
const failures = this.results.filter((r) => !r).length;
return failures / this.results.length;
}
async call(...args) {
if (this.state === 'OPEN') {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new Error('Circuit is open — failing fast');
}
this.state = 'HALF_OPEN';
}
try {
const result = await this.fn(...args);
this.#recordResult(true);
if (this.state === 'HALF_OPEN') this.state = 'CLOSED';
return result;
} catch (err) {
this.#recordResult(false);
if (this.state === 'HALF_OPEN' || this.#failureRate() >= this.failureRateThreshold) {
this.state = 'OPEN';
this.openedAt = Date.now();
}
throw err;
}
}
}
Wohin man den Breaker setzt
Der Breaker sollte pro nachgelagerter Abhängigkeit leben, nicht global – ein ausfallender Empfehlungsdienst sollte keinen Breaker auslösen, der auch die Aufrufe an deinen Zahlungsanbieter sperrt. In einem Dienst mit mehreren externen Abhängigkeiten heißt das: eine Breaker-Instanz pro Abhängigkeit, jeweils mit Schwellen, die auf die tatsächlichen Fehlercharakteristika dieser Abhängigkeit abgestimmt sind (eine wacklige Drittanbieter-API verdient vielleicht eine niedrigere Schwelle und längere Abkühlzeit als ein interner Dienst im selben Netzwerk).
Fazit
Die Aufgabe eines Circuit Breakers ist nicht, Fehler zu verhindern – sondern zu verhindern, dass dein eigener Dienst einen nachgelagerten Ausfall verschlimmert, indem er ihn mit Last bombardiert, die er nicht bewältigen kann, und dass dein eigener Dienst seine Ressourcen mit dem Warten auf Aufrufe verschwendet, die sehr wahrscheinlich ohnehin scheitern. Die Zustandsmaschine ist einfach; richtig hinbekommen sollte man die Wahl von Schwellen und Fenstergrößen, die dazu passen, wie die Abhängigkeit in der Praxis tatsächlich ausfällt, statt einfach runde Zahlen zu wählen.