Le patron Circuit Breaker pour des services résilients
Quand un service en aval commence à échouer, la réponse naïve (réessayer chaque requête à mesure qu'elle arrive) est exactement le mauvais réflexe. Chaque nouvelle tentative est une requête de plus qui s'empile sur un service déjà en difficulté, et chaque appelant bloqué en attendant un timeout est un thread ou une connexion que votre propre service ne peut plus utiliser pour autre chose. Le patron circuit breaker existe pour arrêter cela : après suffisamment d'échecs, il cesse totalement d'envoyer des requêtes pendant un moment, en échouant vite au lieu d'échouer lentement.
Les trois états
Un circuit breaker est une machine à états à trois états, nommés d'après l'analogie électrique :
- Fermé (Closed) : fonctionnement normal. Les requêtes passent vers le service en aval. Les échecs sont comptés.
- Ouvert (Open) : le seuil d'échecs a été franchi. Les requêtes échouent immédiatement, sans même tenter l'appel, pendant une période de refroidissement.
- Semi-ouvert (Half-open) : après le refroidissement, une (ou quelques) requêtes de test passent. Si elles réussissent, le disjoncteur se referme. Si elles échouent, il se rouvre et le refroidissement recommence.
CLOSED --(too many failures)--> OPEN --(cooldown elapses)--> HALF_OPEN
^ |
|______________________(test request succeeds)_________________|
(test request fails) -----> OPEN
L'idée clé se situe dans l'état « ouvert » : au lieu que chaque appelant attende indépendamment un timeout face à un service en panne, le disjoncteur échoue immédiatement et à peu de frais, libérant des ressources de votre côté et laissant au service en aval la marge pour se rétablir sans charge supplémentaire.
Une implémentation minimale
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();
}
}
}
Utilisation : enveloppez n'importe quelle fonction qui effectue un appel risqué :
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' });
}
Notez qu'un seul appel échoué en HALF_OPEN rouvre immédiatement le disjoncteur, sans exiger de nouveau le seuil complet : tout l'intérêt de la sonde semi-ouverte est la prudence, donc un seul mauvais résultat de test suffit pour conclure que le service n'est pas encore prêt.
Circuit breaker, retry et timeout
Ces trois notions sont souvent confondues parce qu'elles répondent toutes à « l'appel en aval ne fonctionne pas », mais elles résolvent des problèmes différents :
- Le timeout borne la durée d'attente d'un seul appel avant d'abandonner.
- Le retry retente un appel échoué, généralement avec un backoff, en supposant que l'échec était passager.
- Le circuit breaker suit les échecs d'un appel à l'autre dans le temps, et cesse complètement de tenter des appels dès qu'un service paraît systématiquement cassé plutôt que passagèrement malchanceux.
Ils se combinent : un appel est généralement enveloppé dans un timeout, réessayé un nombre borné de fois en cas d'échec, et la logique de retry elle-même se place derrière un circuit breaker, pour qu'un service systématiquement en panne ne soit pas martelé par (timeout + nouvelles tentatives) × chaque requête entrante.
Ce que le seuil d'échecs doit compter
Compter les échecs bruts sur une durée non bornée est généralement une erreur : cinq échecs sur cinq requêtes cette minute, c'est un vrai problème ; cinq échecs sur dix mille requêtes en une semaine, c'est du bruit. Une fenêtre glissante (compter les échecs sur les N dernières requêtes ou les N dernières secondes) est le choix pratique :
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;
}
}
}
Où placer le disjoncteur
Le disjoncteur doit exister par dépendance en aval, pas globalement : un service de recommandations défaillant ne doit pas déclencher un disjoncteur qui contrôle aussi les appels vers votre prestataire de paiement. Dans un service avec plusieurs dépendances externes, cela signifie une instance de disjoncteur par dépendance, chacune avec des seuils réglés sur les caractéristiques de défaillance réelles de cette dépendance (une API tierce instable peut justifier un seuil plus bas et un refroidissement plus long qu'un service interne sur le même réseau).
À retenir
Le rôle d'un circuit breaker n'est pas d'empêcher les défaillances : c'est d'empêcher votre propre service d'aggraver une défaillance en aval en la martelant d'une charge qu'elle ne peut pas absorber, et d'empêcher votre propre service de gaspiller ses ressources à attendre des appels très probablement voués à l'échec. La machine à états est simple ; ce qui mérite d'être bien fait, c'est de choisir des seuils et des tailles de fenêtre qui correspondent à la façon dont la dépendance échoue réellement en pratique, et pas seulement de choisir des nombres ronds.