Quand votre application dépend d'API externes — fournisseurs de paiement, marketplaces, services d'expédition — vous acceptez qu'une partie de votre système soit hors de votre contrôle. Ces dépendances peuvent devenir lentes, renvoyer des erreurs ou tout simplement cesser de répondre. Sans protection, une API externe instable peut faire tomber tout votre système. Le Circuit Breaker est le pattern qui évite ça.
Le problème : les défaillances en cascade
Imaginez un scénario réel : votre application appelle l'API d'un marketplace pendant le checkout. Normalement cet appel prend 200ms. Mais le marketplace a des problèmes et commence à mettre 30 secondes à répondre — ou ne répond tout simplement pas.
Ce qui se passe sans protection :
- Le checkout de l'utilisateur se bloque en attendant la réponse du marketplace
- Pendant l'attente, de nouvelles requêtes arrivent et se bloquent elles aussi
- Le pool de connexions de votre serveur s'épuise
- Votre serveur cesse de répondre à tous les utilisateurs — pas seulement à ceux qui dépendent du marketplace
- Le système entier tombe à cause d'une dépendance externe
C'est une défaillance en cascade. Une dépendance instable propage la panne à tout le système. Le Circuit Breaker interrompt cette propagation.
Comment fonctionne le Circuit Breaker
Le pattern fonctionne comme un disjoncteur électrique. Il surveille les appels vers une dépendance externe et possède trois états :
- CLOSED (fermé) — état normal. Les requêtes passent vers l'API externe. Si les échecs s'accumulent au-delà du threshold, il passe à OPEN.
- OPEN (ouvert) — état de protection. Les requêtes n'atteignent pas l'API. Il renvoie un fallback immédiatement. Après une période de cooldown, il passe à HALF-OPEN.
- HALF-OPEN (semi-ouvert) — état de test. Il laisse passer une requête en guise de test. En cas de succès, il revient à CLOSED. En cas d'échec, il revient à OPEN.
Diagramme de la state machine du Circuit Breaker — états CLOSED, OPEN et HALF-OPEN avec les transitionsImplémentation en TypeScript
Je vais implémenter un Circuit Breaker générique capable d'envelopper n'importe quel appel externe. L'idée est qu'il soit réutilisable pour différentes dépendances.
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'
interface CircuitBreakerOptions {
failureThreshold: number // échecs avant ouverture
cooldownMs: number // temps en OPEN avant de tester
timeoutMs: number // timeout par requête
}
class CircuitBreaker {
private state: CircuitState = 'CLOSED'
private failureCount = 0
private lastFailureTime = 0
private readonly options: CircuitBreakerOptions
constructor(options: Partial<CircuitBreakerOptions> = {}) {
this.options = {
failureThreshold: options.failureThreshold ?? 5,
cooldownMs: options.cooldownMs ?? 60_000,
timeoutMs: options.timeoutMs ?? 10_000,
}
}
async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime >= this.options.cooldownMs) {
this.state = 'HALF_OPEN'
} else {
return fallback()
}
}
try {
const result = await this.withTimeout(fn())
this.onSuccess()
return result
} catch (error) {
this.onFailure()
return fallback()
}
}
private onSuccess() {
this.failureCount = 0
this.state = 'CLOSED'
}
private onFailure() {
this.failureCount++
this.lastFailureTime = Date.now()
if (this.failureCount >= this.options.failureThreshold) {
this.state = 'OPEN'
}
}
private withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), this.options.timeoutMs)
),
])
}
getState(): CircuitState {
return this.state
}
}Usage pratique : protéger un appel d'API
Une fois la classe créée, envelopper n'importe quel appel externe est simple :
// Un circuit breaker par dépendance
const marketplaceBreaker = new CircuitBreaker({
failureThreshold: 3, // s'ouvre après 3 échecs
cooldownMs: 30_000, // attend 30s avant de tester
timeoutMs: 5_000, // timeout de 5s par appel
})
const paymentBreaker = new CircuitBreaker({
failureThreshold: 2, // plus sensible — c'est du paiement
cooldownMs: 60_000, // cooldown plus long
timeoutMs: 10_000, // timeout plus long — les fournisseurs de paiement sont lents
})
// Appel protégé
async function getProductFromMarketplace(productId: string) {
return marketplaceBreaker.execute(
// Appel réel
() => fetch(`https://api.marketplace.com/products/${productId}`)
.then(res => res.json()),
// Fallback quand le circuit est ouvert
() => getCachedProduct(productId)
)
}Le point important : chaque dépendance externe doit avoir son propre Circuit Breaker. Si le marketplace tombe, le circuit du marketplace s'ouvre — mais le circuit du fournisseur de paiement reste fermé et continue de fonctionner normalement.
Stratégies de fallback
Le fallback est ce que votre système renvoie quand le circuit est ouvert. C'est la partie qui demande le plus de décisions produit, parce que le fallback doit être suffisamment utile pour que l'utilisateur ne perçoive pas la dégradation. Quelques stratégies :
- Cache — renvoyer la dernière réponse valide. Fonctionne bien pour des données qui changent peu (catalogue de produits, configurations).
- Valeur par défaut — renvoyer une valeur sûre prédéfinie. Fonctionne pour les calculs de frais de port (« frais de port à confirmer ») ou pour le stock (« vérifiez la disponibilité »).
- Fonctionnalité réduite — le checkout fonctionne sans la validation du marketplace, mais prévient l'utilisateur que le prix sera confirmé plus tard.
- File pour retry ultérieur — sauvegarder l'opération pour réessayer quand le service revient. Fonctionne pour les opérations qui n'ont pas besoin d'une réponse immédiate.
// Fallback avec cache
const productCache = new Map<string, Product>()
async function getProduct(id: string): Promise<Product> {
return marketplaceBreaker.execute(
async () => {
const product = await fetchFromMarketplace(id)
productCache.set(id, product) // met à jour le cache en cas de succès
return product
},
() => {
const cached = productCache.get(id)
if (cached) return cached
// Sans cache — renvoie un produit avec un flag d'indisponibilité
return { id, name: 'Produit indisponible', available: false }
}
)
}Combiner avec le retry
Circuit Breaker et retry résolvent des problèmes différents :
- Retry — pour les défaillances ponctuelles (une request a échoué, la suivante fonctionnera probablement). Utilise un backoff exponentiel.
- Circuit Breaker — pour les défaillances durables (le service est en panne, ça ne sert à rien de continuer à essayer). Protège le système entier.
La bonne combinaison : le retry à l'intérieur du circuit breaker. Le circuit surveille si les retries fonctionnent. Si même avec retry l'appel continue d'échouer, le circuit s'ouvre.
async function fetchWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
let lastError: Error | null = null
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
// Backoff exponentiel : 1s, 2s, 4s
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
}
}
throw lastError
}
// Retry à l'intérieur du circuit breaker
async function getProductReliably(id: string) {
return marketplaceBreaker.execute(
() => fetchWithRetry(() => fetchFromMarketplace(id), 3),
() => getCachedProduct(id)
)
}Monitoring : savoir quand le circuit s'ouvre
Un circuit breaker qui s'ouvre silencieusement est dangereux — vous devez savoir quand un service externe est instable. Ajouter des événements de transition d'état règle le problème :
class ObservableCircuitBreaker extends CircuitBreaker {
private name: string
constructor(name: string, options?: Partial<CircuitBreakerOptions>) {
super(options)
this.name = name
}
async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
const previousState = this.getState()
const result = await super.execute(fn, fallback)
const currentState = this.getState()
if (previousState !== currentState) {
this.logTransition(previousState, currentState)
}
return result
}
private logTransition(from: string, to: string) {
const message = `[CircuitBreaker:${this.name}] ${from} → ${to}`
if (to === 'OPEN') {
console.error(message) // alerter quand il s'ouvre
// Envoyer vers le monitoring (Datadog, Sentry, etc.)
} else if (to === 'CLOSED') {
console.info(message) // informer quand il se rétablit
} else {
console.warn(message) // half-open est une transition
}
}
}Avec ça, vous savez exactement quand un service externe commence à échouer et quand il se rétablit. En production, ces logs doivent aller vers un système d'alertes — si le circuit du fournisseur de paiement s'ouvre, quelqu'un doit être notifié.
Scénario réel : API de marketplace incohérente
Un scénario que j'ai rencontré : une API de marketplace renvoyait les données produits de façon incohérente. Parfois elle répondait en 200ms, parfois elle mettait 15 secondes, parfois elle renvoyait 500. Le checkout dépendait de cette API pour valider le prix et le stock.
La solution a été :
- Circuit Breaker avec un threshold de 3 échecs et un cooldown de 30s
- Timeout agressif de 3s (si l'API n'a pas répondu en 3s, c'est considéré comme un échec)
- Cache en fallback — dernière réponse valide du produit, avec un flag « prix sujet à confirmation »
- Réconciliation ultérieure — un job qui tourne toutes les 5 minutes en vérifiant si les prix du cache sont toujours corrects
Le résultat : le checkout ne s'est plus jamais bloqué à cause du marketplace. Quand l'API était instable, les utilisateurs voyaient les prix du cache avec un avertissement discret. Quand elle revenait à la normale, le circuit se fermait et tout se remettait à fonctionner en temps réel.
Quand ne pas utiliser un Circuit Breaker
Tous les appels externes n'ont pas besoin d'un circuit breaker :
- Les appels déjà résilients — si la dépendance a un retry natif et un SLA élevé (ex : AWS S3), l'overhead du circuit peut être inutile.
- Les opérations idempotentes avec file — si vous utilisez déjà une file avec retry (ex : webhook processing), la file joue déjà le rôle du circuit.
- Les appels qui ne sont pas critiques — analytics, logging externe, tracking. En cas d'échec, l'utilisateur n'est pas affecté.
Résumé
Le Circuit Breaker est l'un des outils les plus importants pour les applications qui dépendent de services externes. Le pattern en lui-même est simple — une state machine avec trois états. Ce qui demande de l'ingénierie, c'est :
- Calibrer les thresholds — combien d'échecs avant d'ouvrir, combien de temps de cooldown
- Concevoir des fallbacks utiles — cache, valeur par défaut, fonctionnalité réduite
- Surveiller les transitions — savoir quand il s'est ouvert et quand il s'est fermé
- Un breaker par dépendance — isoler les défaillances pour qu'une API instable n'affecte pas les autres
- Combiner avec le retry — le retry pour les défaillances ponctuelles, le circuit pour les défaillances durables
La différence entre un système qui tombe en même temps que ses dépendances et un système qui se dégrade élégamment, c'est presque toujours un Circuit Breaker bien configuré.
