Circuit breaker en Go : survivre aux pannes d'exchange crypto

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.

Commentaires (0)