# CLAUDE.md — Circuit Breaker en Go > Contexte spécialisé pour Claude Code. Coller ce fichier à la racine du projet pour implémenter un circuit breaker résilient en Go pour la consommation d'APIs externes (exchanges crypto, services tiers instables). --- ## Section 1 : Les trois états du circuit breaker Le circuit breaker est une machine à trois états. Analogie exacte avec un disjoncteur électrique : trop d'erreurs → le circuit s'ouvre et coupe le flux. ```text CLOSED ──(5 échecs en 10s)──► OPEN ▲ │ │ (timeout 30s) │ ▼ └──(sonde réussie)────── HALF-OPEN ``` **Closed (état normal)** : les requêtes passent, les erreurs sont comptabilisées. Tant que le seuil n'est pas atteint, tout continue. **Open (circuit déclenché)** : les requêtes échouent immédiatement, sans appel réseau. Aucune connexion établie. L'état reste ouvert pendant un timeout configurable. **Half-open (tentative de récupération)** : après le timeout, une seule requête "sonde" est autorisée. - Sonde réussie → retour à Closed, compteurs remis à zéro - Sonde échoue → retour à Open, timeout repart L'état Half-open est ce qui distingue un circuit breaker d'un simple "disable" : la récupération est automatique, sans intervention manuelle. --- ## Section 2 : Implémentation Go — moins de 60 lignes Thread-safe avec `sync.Mutex`, zéro dépendance externe. ```go 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 } ``` **Points critiques de l'implémentation :** - Le mutex est relâché **avant** l'appel à `fn()` — le garder acquis bloquerait toutes les goroutines pendant la durée de la requête réseau - La transition Open → HalfOpen se fait à la réception de la prochaine requête, pas via une goroutine en arrière-plan (pas de goroutine leak si le circuit breaker est abandonné) - En HalfOpen, toute erreur repousse directement en Open — la tolérance est nulle sur la sonde --- ## Section 3 : Retry avec backoff exponentiel Le circuit breaker gère les pannes prolongées. Le retry gère les erreurs transitoires (paquet perdu, connexion TCP coupée, 429 passager). Les deux sont complémentaires. ```go 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) } ``` **Règle clé :** `errors.Is(err, ErrCircuitOpen)` interrompt les retries immédiatement. Quand le circuit est ouvert, la prochaine tentative retournera la même erreur dans la microseconde — le backoff exponentiel ne s'applique qu'aux vraies erreurs réseau. Le `select` sur `ctx.Done()` garantit l'arrêt des retries si le contexte parent est annulé. --- ## Section 4 : Intégration avec timeout et context Sans timeout par requête, une connexion peut bloquer indéfiniment si le serveur accepte la 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. ```go 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 de protection :** 1. Timeout (`2s`) : un appel lent échoue vite et incrémente le compteur d'échecs 2. Circuit breaker : ouvre après plusieurs échecs, évite d'appeler un exchange down 3. Fallback : sert des données dégradées quand le circuit est ouvert --- ## Section 5 : Fallback et dégradation gracieuse La stratégie de fallback dépend du type de donnée — c'est une décision **métier**, pas technique. ### Donnée stale acceptable (order book, prix) ```go 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 } ``` ### Donnée stale dangereuse (soldes de compte) ```go 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 } ``` **Règle :** documenter explicitement dans le code pourquoi une donnée est fallbackable ou non. Ne jamais ajouter un fallback "pour que ça marche" sans décision explicite. --- ## Section 6 : sony/gobreaker vs implémentation maison ```go 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) }, }) ``` | Critère | Implémentation maison | gobreaker | |---------|----------------------|-----------| | Dépendances | Zéro | 1 | | Fenêtre glissante | Non | Oui | | Callbacks état | Non | Oui (`OnStateChange`) | | Métriques Prometheus | À implémenter | Via callbacks | | Lisibilité pour 2-3 exchanges | Meilleure | Correcte | | Plateforme 20+ exchanges | Insuffisante | Recommandée | **Règle :** implémentation maison si 2-3 exchanges avec patterns d'erreurs prévisibles. `gobreaker` si besoin de fenêtre glissante ou callbacks Prometheus. --- ## Section 7 : Monitoring et alerting Les changements d'état du circuit breaker sont des signaux critiques à exposer. ```go // Wrapper avec logging structuré sur les transitions d'état type InstrumentedCircuitBreaker struct { cb *CircuitBreaker name string } func (icb *InstrumentedCircuitBreaker) Call(fn func() error) error { prevState := icb.cb.state err := icb.cb.Call(fn) newState := icb.cb.state if prevState != newState { slog.Warn("circuit breaker state changed", "name", icb.name, "from", prevState, "to", newState, ) } if errors.Is(err, ErrCircuitOpen) { slog.Warn("circuit breaker rejected call", "name", icb.name, "state", icb.cb.state, ) } return err } ``` **Métriques à exposer (Prometheus) :** - `circuit_breaker_state` (gauge : 0=closed, 1=open, 2=half-open) - `circuit_breaker_calls_total` (counter par état et résultat) - `circuit_breaker_open_duration_seconds` (histogram)