← Contextes /
circuit-breaker-go.md 311 lignes · 9.5 KB
Personnaliser Télécharger
# 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)