Chaque circuit breaker en production que j’ai examiné finit par lancer un thread d’arrière-plan. Il peut s’agir d’une goroutine Go, d’un ScheduledExecutorService Java, ou d’une tâche tokio Rust. Le travail est toujours le même : se réveiller toutes les quelques secondes, vérifier si le service en aval a récupéré, et passer de OPEN à CLOSED.

Cette conception est mauvaise. Elle fait fuiter des ressources à l’échelle, complique l’arrêt, et crée des race conditions qui sont réellement difficiles à tester. Pire encore, le travail d’arrière-plan est complètement inutile. Vous pouvez construire un circuit breaker qui ne se réveille jamais tout seul, n’alloue jamais de timer, et détecte toujours correctement la récupération.

Le coût caché des goroutines de health-check

Un circuit breaker suit les échecs. Après suffisamment d’erreurs consécutives, il bascule en OPEN et commence à rejeter immédiatement les requêtes. L’objectif est de donner un répit au service en défaillance au lieu de le noyer sous le trafic de retries.

La partie délicate consiste à décider quand réessayer. La plupart des bibliothèques résolvent cela avec un setTimeout ou un time.AfterFunc. En Go, une implémentation typique ressemble à ceci :

func (cb *CircuitBreaker) Trip() {
    cb.state.Store(StateOpen)
    time.AfterFunc(cb.timeout, func() {
        cb.state.Store(StateHalfOpen)
    })
}

Cela fonctionne pour un seul breaker. Cela ne fonctionne pas pour dix mille.

Si vous créez un circuit breaker par hôte en aval (un pattern courant dans les microservices), vous vous retrouvez avec dix mille goroutines dormant en arrière-plan. Chaque goroutine coûte ~2 Ko d’espace de pile et ajoute de l’overhead d’ordonnancement. Lors des redémarrages de containers, ces goroutines entrent en race condition contre les deadlines d’arrêt. Lors des timeouts, elles se déclenchent exactement au mauvais moment et créent du flapping.

Le thread d’arrière-plan résout un problème qui n’existe pas. La récupération n’a pas besoin d’être détectée de manière proactive. Elle peut être détectée de manière paresseuse, sur le chemin de la requête.

Comment fonctionne la récupération paresseuse

Au lieu d’un timer qui fait transitionner le breaker, stockez un simple timestamp : le moment où le breaker a basculé en OPEN. À chaque requête entrante, comparez now à ce timestamp plus le timeout configuré. Si suffisamment de temps s’est écoulé, laissez passer une seule probe. Si la probe réussit, fermez le breaker. Si elle échoue, mettez à jour le timestamp et restez en OPEN.

La state machine reste identique. Seul le déclencheur de transition change.

  • CLOSED : les requêtes passent. Les échecs incrémentent un compteur. Quand le compteur atteint le seuil, basculez atomiquement en OPEN et enregistrez trippedAt.
  • OPEN : chaque requête entrante vérifie time.Now() > trippedAt + timeout. Si faux, échec rapide. Si vrai, basculez atomiquement en HALF-OPEN et laissez passer cette unique requête.
  • HALF-OPEN : exactement une requête est en cours. Si elle réussit, basculez en CLOSED et réinitialisez le compteur d’échecs. Si elle échoue, rebasculez en OPEN et mettez à jour trippedAt.

Aucune goroutine ne se réveille jamais. Aucun timer n’est alloué. Le breaker est entièrement passif jusqu’à l’arrivée d’une requête.

Une implémentation fonctionnelle en Go

Voici un circuit breaker complet, sans overhead d’arrière-plan. Il utilise uniquement sync/atomic pour les transitions d’état et stocke le timestamp de basculement comme un compteur de nanosecondes.

package breaker

import (
	"errors"
	"sync/atomic"
	"time"
)

type State int32

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

type CircuitBreaker struct {
	// state is accessed with atomic operations.
	state      int32
	failures   int32
	threshold  int32
	timeout    time.Duration
	trippedAt  int64 // nanoseconds since Unix epoch
}

func New(threshold int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold: int32(threshold),
		timeout:   timeout,
	}
}

func (cb *CircuitBreaker) State() State {
	return State(atomic.LoadInt32(&cb.state))
}

// Allow reports whether the current request may proceed.
// It returns a done function that must be called with the outcome.
func (cb *CircuitBreaker) Allow() (done func(success bool), err error) {
	switch State(atomic.LoadInt32(&cb.state)) {
	case StateClosed:
		return cb.trackClosed, nil

	case StateOpen:
		// Lazy recovery check: has the timeout elapsed?
		if time.Now().UnixNano()-atomic.LoadInt64(&cb.trippedAt) < int64(cb.timeout) {
			return nil, errors.New("circuit breaker is open")
		}
		// Race: multiple goroutines may see this simultaneously.
		// Only one wins the CAS to HALF-OPEN.
		if atomic.CompareAndSwapInt32(&cb.state, int32(StateOpen), int32(StateHalfOpen)) {
			return cb.trackHalfOpen, nil
		}
		// Another goroutine won the race; fail fast this request.
		return nil, errors.New("circuit breaker is open")

	case StateHalfOpen:
		// Only one probe at a time. Every other request fails fast.
		return nil, errors.New("circuit breaker is half-open")
	}

	return nil, errors.New("unknown circuit breaker state")
}

func (cb *CircuitBreaker) trackClosed(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		return
	}

	// Increment failures and trip if threshold reached.
	if atomic.AddInt32(&cb.failures, 1) >= cb.threshold {
		// Record the trip time before switching state so readers
		// never see OPEN with a stale trippedAt.
		atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
		atomic.StoreInt32(&cb.state, int32(StateOpen))
	}
}

func (cb *CircuitBreaker) trackHalfOpen(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		atomic.StoreInt32(&cb.state, int32(StateClosed))
		return
	}

	atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
	atomic.StoreInt32(&cb.state, int32(StateOpen))
}

L’invariant clé : trippedAt est toujours écrit avant que l’état ne bascule en OPEN. Les lecteurs dans Allow() peuvent alors lire trippedAt en toute sécurité après avoir vu OPEN, en sachant qu’il est frais. Sur le chemin de retour depuis HALF-OPEN, nous mettons à jour trippedAt avant de retomber en OPEN pour que le cooldown redémarre à zéro.

Pourquoi la plupart des bibliothèques ne font pas ça

La conception paresseuse a un inconvénient apparent : la récupération n’est détectée que lorsqu’une requête arrive. Si votre service ne reçoit aucun trafic pendant une heure, le breaker reste OPEN pendant une heure.

Cela semble mauvais. Ce n’est pas le cas.

S’il n’y a pas de requêtes, il n’y a rien à protéger. Le breaker existe pour empêcher les échecs en cascade pendant le trafic, pas pour maintenir un tableau de bord de santé en temps réel. Quand la prochaine requête arrive, la vérification du timeout s’exécute en nanosecondes et la probe déclenche immédiatement. La latence de récupération effective est bornée par max(timeout, time-between-requests).

Pour les services à fort trafic, l’intervalle entre les requêtes est négligeable. Pour les services à faible trafic, le timeout domine de toute façon. Le timer d’arrière-plan n’améliore presque jamais le temps de récupération réel en pratique.

L’autre raison pour laquelle les bibliothèques utilisent des timers est historique. Le pattern circuit breaker a été popularisé dans des environnements (Java avec Hystrix, .NET avec Polly) où une seule instance de breaker gardait une dépendance de service entière, pas une connexion par hôte. Un thread d’arrière-plan était acceptable. Dans les systèmes distribués modernes, où vous pouvez avoir un breaker par endpoint amont, cette hypothèse s’effondre.

Tester les race conditions

La boucle CAS sur la transition de OPEN à HALF-OPEN est le seul endroit où les goroutines entrent en contention. Si deux requêtes arrivent simultanément après le timeout, une seule continue comme probe. L’autre échoue rapidement et réessaie à la prochaine requête. C’est un comportement correct. Vous ne voulez jamais de plusieurs probes en vol pendant la récupération, car un seul échec parmi plusieurs succès pourrait toujours vous faire rebasculer en OPEN.

Les tests sont simples car il n’y a pas de timers asynchrones. Vous pouvez écrire un test unitaire qui manipule trippedAt directement (ou utilise un wrapper de temps) sans sleep :

func TestLazyRecovery(t *testing.T) {
	cb := New(1, time.Minute)

	// Trip the breaker.
	done, _ := cb.Allow()
	done(false)

	if cb.State() != StateOpen {
		t.Fatal("expected OPEN")
	}

	// Simulate timeout by winding back trippedAt.
	atomic.StoreInt64(&cb.trippedAt, time.Now().Add(-2*time.Minute).UnixNano())

	done, err := cb.Allow()
	if err != nil {
		t.Fatalf("expected probe to be allowed: %v", err)
	}

	// Success closes the breaker.
	done(true)
	if cb.State() != StateClosed {
		t.Fatal("expected CLOSED after successful probe")
	}
}

Pas de time.Sleep. Pas de sync.WaitGroup pour les goroutines. Le test est déterministe car l’implémentation est synchrone.

Ce que nous avons sacrifié

Il y a une vraie perte : vous ne pouvez pas préchauffer proactivement un breaker avant d’envoyer du trafic. Si vous avez besoin de sonder une dépendance selon un calendrier fixe (disons, toutes les 5 secondes) pour garder un pool de connexions chaud, vous avez toujours besoin d’un timer. Mais ce timer appartient à votre pool de connexions ou à votre health checker, pas au circuit breaker. Le breaker devrait protéger le pool. Il ne devrait pas le gérer.

Gardez les préoccupations séparées. Le health checking préchauffe les connexions. Le circuit breaking empêche la surcharge en cascade. Quand vous les fusionnez, vous obtenez de la complexité des deux côtés.

Un pattern pour les autres langages

La même structure fonctionne partout où vous avez un compare-and-swap atomique et une horloge monotone. En Rust avec std::sync::atomic, en Java avec AtomicIntegerFieldUpdater et System.nanoTime(), en C++ avec std::atomic et une enum personnalisée. L’implémentation fait moins de cent lignes dans chaque cas.

Si votre langage n’expose pas de CAS, un sync.Mutex (ou équivalent) reste moins cher qu’un thread d’arrière-plan. Le mutex n’est tenu que pendant des nanosecondes par requête, et uniquement pendant les transitions d’état. Il ne bloque jamais pour de l’E/S ou ne dort jamais.

Essayez-le

L’implémentation complète ci-dessus est prête pour la production comme point de départ. Ajoutez des métriques, du logging, et des seuils adaptatifs par-dessus. Mais laissez de côté la goroutine. Votre ordonnanceur d’exécution vous remerciera, vos tests s’exécuteront plus vite, et vous cesserez de vous demander pourquoi ce container refuse de s’arrêter proprement.