Binance annonce une maintenance à 2h du matin. Votre service continue de taper dessus à 10 req/sec pendant 30 minutes. Résultat : 18 000 requêtes perdues, des goroutines qui s'accumulent pendant que chaque appel attend son timeout, et la mémoire qui grimpe progressivement jusqu'à ce que le scheduler commence à souffrir.
Le circuit breaker coûte 50 lignes à écrire. La maintenance de Binance, elle, est inévitable. OKEx retourne des 503 aléatoires. Coinbase rate limite sans prévenir. Les exchanges crypto ont des SLA qui feraient fuir n'importe quel ops habitué à AWS. Si votre service consomme plusieurs d'entre eux, la résilience n'est pas optionnelle.
L'article précédent couvrait le rate limiter : comment éviter de dépasser les quotas d'une API. Ce pattern répond à une autre question — que faire quand l'API est down, pas juste lente ?
Les trois états du circuit breaker
Le circuit breaker est une machine à trois états. L'analogie avec un disjoncteur électrique est exacte : quand trop d'erreurs surviennent, le circuit s'ouvre et coupe le flux.
Closed (fermé) — état normal. Les requêtes passent. Les erreurs sont comptabilisées. Tant que le seuil n'est pas atteint, tout continue normalement.
Open (ouvert) — circuit déclenché. Les requêtes échouent immédiatement, sans appel à l'exchange. Aucune connexion réseau n'est établie. Le service en amont ne sait pas que l'exchange est down — il reçoit juste une erreur rapide. L'état reste ouvert pendant un timeout configurable.
Half-open (semi-ouvert) — tentative de récupération. Après le timeout, une seule requête "sonde" est autorisée. Si elle réussit : retour à Closed, compteurs remis à zéro. Si elle échoue : retour à Open, timeout repart.
CLOSED ──(5 échecs en 10s)──► OPEN
▲ │
│ (timeout 30s)
│ ▼
└──(sonde réussie)────── HALF-OPEN
L'état Half-open est ce qui distingue un circuit breaker d'un simple "disable". La récupération est automatique — le service reprend dès que l'exchange revient, sans intervention manuelle ni redémarrage.
Implémentation en Go
L'implémentation tient en moins de 60 lignes. Pas de dépendance externe,
thread-safe avec un simple sync.Mutex.
package circuit
import (
"errors"
"sync"
"time"
)
var ErrCircuitOpen = errors.New("circuit breaker open")
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
type CircuitBreaker struct {
mu sync.Mutex
state State
failures int
lastFailure time.Time
threshold int // échecs avant ouverture
timeout time.Duration // durée de l'état Open
}
func New(threshold int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
timeout: timeout,
}
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
switch cb.state {
case StateOpen:
if time.Since(cb.lastFailure) > cb.timeout {
cb.state = StateHalfOpen
} else {
cb.mu.Unlock()
return ErrCircuitOpen
}
}
cb.mu.Unlock()
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= cb.threshold || cb.state == StateHalfOpen {
cb.state = StateOpen
}
return err
}
// succès : réinitialisation
cb.failures = 0
cb.state = StateClosed
return nil
}
Quelques détails importants. Le mutex est relâché avant l'appel à fn() —
si on le gardait acquis, toutes les goroutines concurrent bloqueraient pendant la durée
de la requête réseau. Ce serait encore pire que le problème qu'on cherche à résoudre.
La transition Open → HalfOpen se fait à la réception de la prochaine requête après le timeout, pas via une goroutine en arrière-plan. Simple, sans timer, sans goroutine qui leak si le circuit breaker est abandonné.
En HalfOpen, toute erreur repousse directement en Open. Ce n'est pas le moment d'être tolérant — si la sonde échoue, l'exchange n'est pas encore revenu.
Retry avec backoff exponentiel
Le circuit breaker gère les pannes prolongées. Le retry gère les erreurs transitoires : un paquet perdu, une connexion TCP coupée, un 429 passager. Les deux patterns sont complémentaires, pas redondants.
func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
var err error
for i := 0; i < maxAttempts; i++ {
err = fn()
if err == nil {
return nil
}
// Circuit ouvert = panne structurelle, pas une erreur transitoire
// Retenter immédiatement ne servirait à rien
if errors.Is(err, ErrCircuitOpen) {
return err
}
wait := time.Duration(math.Pow(2, float64(i))) * 100 * time.Millisecond
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("after %d attempts: %w", maxAttempts, err)
}
Le point clé est le errors.Is(err, ErrCircuitOpen).
Quand le circuit est ouvert, retenter est inutile : la prochaine tentative
retournera la même erreur dans la microseconde. Le backoff exponentiel
s'applique uniquement aux vraies erreurs réseau.
Le select sur ctx.Done() garantit que les retries
s'arrêtent si le contexte parent est annulé — une requête HTTP annulée par
le client, un shutdown du serveur, un timeout global.
Sans ça, la goroutine continue à réessayer dans le vide.
Timeout et context : la troisième ligne de défense
Sans timeout par requête, une connexion vers Binance peut bloquer indéfiniment si le serveur accepte la connexion TCP mais ne répond jamais. Le circuit breaker ne se déclenche pas — les appels n'échouent pas, ils attendent. Les goroutines s'accumulent. Le symptôme est le même qu'une panne franche, mais le mécanisme de protection ne se déclenche pas.
type ExchangeService struct {
binance *BinanceClient
cb *CircuitBreaker
}
func (s *ExchangeService) GetOrderBook(ctx context.Context, pair string) (*OrderBook, error) {
// Timeout par requête, indépendant du contexte parent
callCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var result *OrderBook
err := s.cb.Call(func() error {
var err error
result, err = s.binance.GetOrderBook(callCtx, pair)
return err
})
if err != nil {
if errors.Is(err, ErrCircuitOpen) {
// Circuit ouvert : servir le fallback plutôt qu'une erreur 500
return s.getFallbackOrderBook(pair)
}
return nil, err
}
return result, nil
}
Les trois couches jouent des rôles distincts. Le timeout (2s)
garantit qu'un appel lent échoue vite et fait monter le compteur d'échecs
du circuit breaker. Le circuit breaker ouvre après plusieurs échecs et évite
d'appeler un exchange clairement down. Le fallback sert des données dégradées
quand le circuit est ouvert.
Le callCtx dérive du contexte parent : si le contexte parent
est annulé (requête HTTP annulée par le client), l'appel à l'exchange
s'arrête aussi. Le timeout de 2s est une borne supérieure, pas une durée garantie.
Fallback et dégradation gracieuse
Quand le circuit est ouvert, que retourner ? La réponse dépend du type de donnée.
Order book, prix mid, spread. La dernière valeur connue avec un timestamp est acceptable sur quelques minutes. Un order book vieux de 3 minutes vaut mieux qu'une erreur 503 qui fait crasher le service appelant. Le cache doit indiquer l'âge de la donnée pour que le consommateur puisse décider.
type CachedOrderBook struct {
Data *OrderBook
FetchedAt time.Time
}
func (s *ExchangeService) getFallbackOrderBook(pair string) (*OrderBook, error) {
s.cacheMu.RLock()
cached, ok := s.cache[pair]
s.cacheMu.RUnlock()
if !ok {
return nil, fmt.Errorf("circuit open and no cached data for %s", pair)
}
// Avertir si la donnée est trop vieille
if time.Since(cached.FetchedAt) > 10*time.Minute {
slog.Warn("serving stale order book", "pair", pair,
"age", time.Since(cached.FetchedAt))
}
return cached.Data, nil
}
Soldes de compte. Une donnée stale est ici dangereuse. Si le service prend des décisions de trading sur un solde vieux de 10 minutes, il peut dépasser un solde réel. Pour ce type de donnée, la bonne réponse est une erreur explicite — pas un fallback silencieux.
func (s *ExchangeService) GetBalance(ctx context.Context) (*Balance, error) {
callCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var result *Balance
err := s.cb.Call(func() error {
var err error
result, err = s.binance.GetBalance(callCtx)
return err
})
if err != nil {
// Pas de fallback pour les soldes — une donnée stale est pire qu'une erreur
return nil, fmt.Errorf("balance unavailable: %w", err)
}
return result, nil
}
La distinction "donnée stale acceptable / donnée stale dangereuse" est une décision métier, pas technique. Elle doit être prise par domaine, par endpoint, et documentée explicitement dans le code — pas laissée à l'appréciation du développeur qui ajoute un fallback "pour que ça marche".
sony/gobreaker vs implémentation maison
La bibliothèque github.com/sony/gobreaker
est la référence Go pour les circuit breakers. Elle est battle-tested,
bien documentée, et couvre des cas que l'implémentation ci-dessus ignore :
comptage par fenêtre glissante, callbacks sur les changements d'état,
conditions de succès/échec configurables.
import "github.com/sony/gobreaker"
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "binance-orderbook",
MaxRequests: 1, // sondes en HalfOpen
Interval: 10 * time.Second, // fenêtre de comptage
Timeout: 30 * time.Second, // durée état Open
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures >= 5
},
OnStateChange: func(name string, from, to gobreaker.State) {
slog.Info("circuit breaker state change",
"name", name, "from", from, "to", to)
},
})
L'implémentation maison couvre 90% des besoins sans dépendance.
gobreaker vaut la dépendance si vous avez besoin de la fenêtre glissante
(pour éviter qu'un pic de 5 erreurs en 1 seconde ouvre le circuit alors que
le taux d'erreur global est bas) ou des callbacks pour alimenter des métriques Prometheus.
Pour un service qui consomme 2-3 exchanges avec des patterns d'erreurs prévisibles,
l'implémentation maison est plus lisible et plus facile à adapter.
Pour une plateforme qui gère 20 exchanges avec des SLA et des dashboards,
gobreaker s'impose.
Conclusion
Le vrai coût d'une maintenance Binance sans circuit breaker n'est pas les 18 000 requêtes perdues — c'est la dégradation silencieuse. Les goroutines qui s'accumulent ne produisent pas d'erreur immédiate. La mémoire grimpe lentement. Le service reste "up" au sens des health checks, mais il est en train de mourir.
Circuit breaker + retry + timeout sont trois mécanismes distincts qui protègent contre trois classes de problèmes différentes : les pannes prolongées, les erreurs transitoires, et les connexions suspendues. Les trois ensemble forment une couche de résilience qui laisse votre service indifférent aux incidents des exchanges.
La décision de fallback, elle, est la seule qui demande une vraie réflexion. Du code technique peut être copié-collé. Décider si un order book vieux de 5 minutes est acceptable dans votre contexte métier — ça, personne d'autre ne peut le faire à votre place.