Le modèle Circuit Breaker en Go : stopper les pannes en cascade

Arrêtez les échecs en cascade dans les microservices Go.

Sommaire

Un disjoncteur empêche votre service Go de bombarder une dépendance défaillante, prévenant ainsi les pannes en cascade qui consomment des goroutines, des sockets et de la mémoire jusqu’à ce que l’ensemble du système s’effondre.

La partie difficile n’est pas la machine à états. Il s’agit de décider où placer le disjoncteur, ce qui constitue une erreur, comment il interagit avec les délais d’expiration (timeouts) et les tentatives de reconnexion (retries), et ce que votre service doit faire lorsque le circuit est ouvert.

circuit breaker

En Go, le modèle de disjoncteur est particulièrement utile pour les appels sortants : API HTTP, passerelles de paiement, services de recherche, fournisseurs d’e-mail, passerelles LLM, microservices internes et autres dépendances qui peuvent devenir lentes, surchargées ou partiellement indisponibles. Bien utilisé, un disjoncteur réduit les pannes en cascade. Mal utilisé, il devient un mode de défaillance obscur supplémentaire.

Quel problème un disjoncteur résout-il ?

Les systèmes distribués échouent rarement de manière propre.

Une dépendance n’est peut-être pas totalement hors service. Elle peut :

  • renvoyer des erreurs 500
  • renvoyer des réponses de limitation de débit 429
  • accepter les connexions TCP mais ne jamais répondre
  • répondre en 30 secondes au lieu de 300 millisecondes
  • échouer uniquement pour certaines requêtes
  • être surchargée car chaque client réessaie simultanément

Le pire scénario n’est souvent pas une panne sèche. C’est une dépendance lente.

Les appels lents consomment des goroutines, des sockets, des connexions à la base de données, de la mémoire et la capacité des travailleurs. Si votre service continue d’attendre une dépendance déjà en mauvaise santé, votre service peut également devenir instable.

Un disjoncteur empêche cela en échouant rapidement après que la dépendance a franchi un seuil d’échec.

Au lieu de faire cela indéfiniment :

requête -> appel dépendance -> attendre -> expiration -> réessayer -> attendre -> échec

le service finit par faire cela :

requête -> circuit ouvert -> retourne une valeur par défaut ou une erreur immédiatement

Cette erreur rapide n’est pas toujours agréable, mais elle est prévisible. Une panne prévisible est plus facile à gérer qu’un effondrement lent.

Les trois états du disjoncteur

La plupart des disjoncteurs utilisent trois états.

Fermé

Le circuit est fermé pendant le fonctionnement normal.

Les requêtes sont autorisées. Le disjoncteur enregistre les succès et les échecs. Si le nombre ou le ratio d’échecs dépasse un seuil, le disjoncteur s’ouvre.

Fermé ne signifie pas « sûr pour toujours ». Cela signifie « le trafic est actuellement autorisé ».

Ouvert

Le circuit est ouvert lorsque la dépendance est considérée comme instable.

Les requêtes sont rejetées immédiatement. Le service doit retourner une valeur par défaut, une réponse mise en cache, une réponse dégradée ou une erreur claire en amont.

Ouvert ne corrige pas la dépendance. Il donne à la dépendance le temps de récupérer et protège l’appelant de l’exploitation inutile des ressources.

Demi-ouvert

Après une période de refroidissement, le disjoncteur entre dans un état demi-ouvert.

Un nombre limité de requêtes d’essai est autorisé. Si elles réussissent, le disjoncteur se ferme. Si elles échouent, le disjoncteur s’ouvre à nouveau.

L’état demi-ouvert est important car il évite deux extrêmes néfastes :

  • ne jamais réessayer la dépendance
  • renvoyer tout le trafic trop rapidement

Les transitions d’état ressemblent à ceci :

stateDiagram-v2 [*] --> Closed Closed --> Open: Failure threshold reached Open --> HalfOpen: Timeout elapsed HalfOpen --> Closed: Trial succeeds HalfOpen --> Open: Trial fails

Disjoncteur vs Délai d’expiration vs Tentative de reconnexion

Une erreur courante est de traiter les disjoncteurs, les tentatives de reconnexion et les délais d’expiration comme interchangeables. Ils sont liés, mais ils résolvent des problèmes différents.

Délai d’expiration (Timeout)

Un délai d’expiration limite la durée d’exécution d’une opération.

En Go, cela signifie généralement passer un context.Context avec un délai ou une expiration dans l’appel sortant.

Un délai d’expiration répond à cette question :

Combien de temps suis-je prêt à attendre pour cet appel unique ?

Tentative de reconnexion (Retry)

Une tentative de reconnexion répète une opération lorsque l’échec peut être temporaire.

Les reconnections sont utiles pour les interruptions réseau courtes, les réponses 503 temporaires, les réinitialisations de connexion et autres échecs transitoires.

Une tentative de reconnexion répond à cette question :

Devrais-je réessayer cet appel ?

Disjoncteur

Un disjoncteur arrête les appels lorsque la dépendance est probablement instable.

Il répond à cette question :

Devrais-je appeler cette dépendance du tout en ce moment ?

Limiteur de débit (Rate Limiter)

Un limiteur de débit contrôle la quantité de trafic autorisée sur une période donnée.

Il répond à cette question :

Quelle quantité de trafic cet appelant doit-il envoyer ?

Bulkhead (Bulkhead)

Un bulkhead isole les ressources afin qu’une dépendance ne puisse pas tout consommer.

Il répond à cette question :

Quelle partie de mon service cette dépendance peut-elle endommager ?

Ces modèles sont les plus efficaces lorsqu’ils sont utilisés ensemble. Un disjoncteur sans délais d’expiration est faible. Les reconnections sans aléa (jitter) peuvent créer des tempêtes de reconnection. Une valeur par défaut sans métriques peut masquer une panne.

Quand utiliser un disjoncteur en Go

Utilisez un disjoncteur lorsque votre service appelle une dépendance qui peut échouer indépendamment de votre service.

De bons candidats incluent :

  • API HTTP externes
  • processeurs de paiement
  • fournisseurs d’e-mail et de SMS
  • services de recherche
  • services de recommandation
  • passerelles d’inférence LLM
  • points de terminaison des microservices internes
  • API SaaS tierces
  • services de lecture lents ou surchargés

Les disjoncteurs sont particulièrement utiles lorsque l’appelant peut dégrader gracieusement le service.

Par exemple :

  • retourner des données produits mises en cache
  • sauter un bloc de recommandation
  • marquer un fournisseur de paiement comme temporairement indisponible
  • mettre en file d’attente le travail pour plus tard
  • retourner une réponse partielle
  • échouer rapidement avec une erreur temporaire claire

La question importante n’est pas « cet appel peut-il échouer ? ». Tout peut échouer. La meilleure question est :

Si cette dépendance échoue, devons-nous continuer à lui envoyer tout le trafic ?

Si la réponse est non, un disjoncteur peut aider.

Quand ne pas utiliser un disjoncteur

N’ajoutez pas un disjoncteur à chaque fonction simplement parce que le modèle semble responsable.

Un disjoncteur n’est généralement pas utile pour :

  • les appels de fonctions locaux dans le processus
  • les opérations CRUD simples dans un monolithe
  • la logique de validation
  • les règles métier déterministes
  • les opérations locales uniquement CPU
  • les chemins de code où aucune valeur par défaut utile n’existe
  • les opérations d’écriture qui ne sont pas idempotentes
  • les dépendances déjà protégées par une couche de workflow plus robuste

Un disjoncteur ne remplace pas non plus l’hygiène de base :

  • définir des délais d’expiration
  • propager le contexte
  • utiliser les pools de connexions correctement
  • gérer les erreurs explicitement
  • rendre les reconnections sûres
  • observer les taux d’échec

Un mauvais disjoncteur peut rendre un système plus difficile à comprendre. Il peut masquer le vrai problème, rejeter le trafic de manière trop agressive ou créer un comportement confus pendant la récupération.

La règle légèrement opinionnée est simple :

Ajoutez des disjoncteurs aux limites des dépendances, pas partout.

Choisir une bibliothèque de disjoncteur Go

Vous pouvez implémenter un disjoncteur de base vous-même, mais la plupart des services Go en production devraient utiliser une bibliothèque.

Le choix simple le plus courant est sony/gobreaker.

Il vous offre :

  • états fermé, ouvert et demi-ouvert
  • seuils d’échec configurables
  • délai d’expiration de l’état ouvert configurable
  • rappels de changement d’état
  • compteurs de requêtes
  • support générique en v2
  • une surface API petite

Pour des pipelines de résilience plus importants, vous pouvez également consulter des bibliothèques qui composent plusieurs politiques, telles que la reconnection, le délai d’expiration, la valeur par défaut, la limitation de débit, l’isolation bulkhead et le disjoncteur. Cela peut être utile lorsque vous souhaitez une couche de résilience unique autour d’une opération.

Cependant, pour de nombreux services Go, gobreaker suffit.

Comparaison des packages de disjoncteur Go

Go n’inclut pas de disjoncteur intégré dans la bibliothèque standard. En pratique, vous choisissez généralement entre une petite bibliothèque de disjoncteur, un framework de résilience plus important ou un package de style Hystrix plus ancien.

Pour la plupart des nouveaux services Go, la décision est simple :

  • utilisez sony/gobreaker si vous souhaitez un disjoncteur petit et ciblé
  • utilisez failsafe-go si vous souhaitez des disjoncteurs composés avec des reconnections, des délais d’expiration, des valeurs par défaut, des bulkheads, des limites de débit et d’autres politiques de résilience
  • évitez de démarrer de nouveaux projets sur hystrix-go à moins que vous n’ayez déjà du code legacy qui l’utilise
Package Idéal pour Forces Inconvénients
sony/gobreaker/v2 Disjoncteurs simples autour des clients HTTP/RPC API petite, support générique v2, modèle d’état clair, facile à envelopper pour les clients dépendants Ne résout que le disjoncteur ; les reconnections, les délais d’expiration et les valeurs par défaut doivent être composés séparément
failsafe-go Composition complète des politiques de résilience Reconnection, valeur par défaut, disjoncteur, délai d’expiration, bulkhead, limiteur de débit, cache, hedge, limiteur adaptatif et throttleur adaptatif Plus de concepts à apprendre ; plus lourd que nécessaire si vous ne voulez qu’un disjoncteur basique
afex/hystrix-go Systèmes de style Hystrix legacy Concepts Hystrix familiers, exécution style commande, usage historique Conception plus ancienne ; n’est pas la valeur par défaut idéale pour les nouveaux services Go
go-kit/kit/circuitbreaker Services basés sur les points de terminaison Go kit S’adapte au style middleware Go kit et à l’architecture des points de terminaison Principalement utile si votre service utilise déjà Go kit
cep21/circuit Comportement de disjoncteur similaire à Hystrix Approche style Hystrix plus fonctionnelle Moins courant comme choix simple par défaut ; peut être excessif pour les petits services

Ma recommandation par défaut est ennuyeuse par intention : commencez avec sony/gobreaker/v2 lorsque vous avez seulement besoin d’un disjoncteur. Tournez-vous vers failsafe-go lorsque vous voulez exprimer une politique de résilience complète en un seul endroit.

Cette séparation maintient l’architecture propre. Un petit client de service n’a pas besoin d’un framework de résilience complet juste pour arrêter d’appeler une dépendance défaillante. Mais une passerelle, un agrégateur, un SDK client API ou une couche d’intégration à fort trafic peut bénéficier de politiques composées.

Installation de gobreaker

Utilisez le package v2 pour le nouveau code :

go get github.com/sony/gobreaker/v2

Puis importez-le :

import "github.com/sony/gobreaker/v2"

Un disjoncteur basique en Go

Voici un petit exemple autour d’un appel HTTP.

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "time"

    "github.com/sony/gobreaker/v2"
)

var ErrTemporaryUnavailable = errors.New("dependency temporarily unavailable")

type UserClient struct {
    baseURL string
    http    *http.Client
    cb      *gobreaker.CircuitBreaker[[]byte]
}

func NewUserClient(baseURL string) *UserClient {
    settings := gobreaker.Settings{
        Name:        "user-service",
        MaxRequests: 3,
        Interval:    30 * time.Second,
        Timeout:     10 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            return counts.ConsecutiveFailures >= 5
        },
        OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
            fmt.Printf("circuit breaker %s changed from %s to %s\n", name, from, to)
        },
    }

    return &UserClient{
        baseURL: baseURL,
        http: &http.Client{
            Timeout: 3 * time.Second,
        },
        cb: gobreaker.NewCircuitBreaker[[]byte](settings),
    }
}

func (c *UserClient) GetUser(ctx context.Context, userID string) ([]byte, error) {
    result, err := c.cb.Execute(func() ([]byte, error) {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            c.baseURL+"/users/"+userID,
            nil,
        )
        if err != nil {
            return nil, err
        }

        resp, err := c.http.Do(req)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            return nil, fmt.Errorf("user service returned %d", resp.StatusCode)
        }

        if resp.StatusCode == http.StatusNotFound {
            return nil, fmt.Errorf("user not found")
        }

        if resp.StatusCode >= 400 {
            return nil, fmt.Errorf("user service client error: %d", resp.StatusCode)
        }

        return io.ReadAll(resp.Body)
    })

    if errors.Is(err, gobreaker.ErrOpenState) {
        return nil, ErrTemporaryUnavailable
    }

    if errors.Is(err, gobreaker.ErrTooManyRequests) {
        return nil, ErrTemporaryUnavailable
    }

    return result, err
}

Ce n’est pas un client de production complet, mais cela montre la forme :

  • le disjoncteur enveloppe l’appel sortant
  • la requête HTTP reçoit un contexte
  • le client HTTP a un délai d’expiration
  • les échecs côté serveur comptent comme des échecs du disjoncteur
  • les erreurs de circuit ouvert sont mappées vers une erreur d’application

Configuration des paramètres gobreaker

Les paramètres clés méritent d’être compris.

Name

Name identifie le disjoncteur.

Utilisez un nom stable et spécifique :

payment-api
search-service
llm-gateway
user-service

Évitez les noms vagues comme :

http-client
external-call
default

Vous voudrez ce nom dans les journaux et les métriques.

MaxRequests

MaxRequests contrôle le nombre de requêtes autorisées tandis que le disjoncteur est demi-ouvert.

Un petit nombre est généralement plus sûr. Le but du demi-ouvert est de tester la récupération, pas d’envoyer tout le trafic immédiatement.

Interval

Interval contrôle quand les comptes internes sont effacés tandis que le disjoncteur est fermé.

Si c’est zéro, les comptes ne sont pas effacés automatiquement. Un intervalle non nul donne au disjoncteur une fenêtre mémoire roulante, bien que ce ne soit pas la même chose qu’une implémentation complète de fenêtre glissante.

Timeout

Timeout contrôle combien de temps le disjoncteur reste ouvert avant de passer en demi-ouvert.

Si le délai d’expiration est trop court, votre service continuera à sonder une dépendance qui ne s’est pas récupérée. S’il est trop long, la récupération sera retardée.

Commencez par quelque chose de conservateur, comme 10 à 30 secondes, puis ajustez à partir des métriques de production.

ReadyToTrip

ReadyToTrip décide quand le disjoncteur devrait s’ouvrir.

Une règle simple est les échecs consécutifs :

ReadyToTrip: func(counts gobreaker.Counts) bool {
    return counts.ConsecutiveFailures >= 5
}

C’est facile à raisonner, mais cela peut ne pas convenir aux services à fort volume.

Une autre option est le ratio d’échec après un nombre minimum de requêtes :

ReadyToTrip: func(counts gobreaker.Counts) bool {
    total := counts.Requests
    failures := counts.TotalFailures

    if total < 20 {
        return false
    }

    return float64(failures)/float64(total) >= 0.5
}

Cela évite d’ouvrir le circuit après une taille d’échantillon minuscule.

OnStateChange

OnStateChange est l’endroit où vous devez émettre des journaux ou des métriques.

Enregistrez au minimum :

  • nom du disjoncteur
  • ancien état
  • nouvel état
  • horodatage

Pour les systèmes de production, exposez l’état du disjoncteur comme une métrique. Les journaux sont utiles pour le débogage, mais les métriques sont meilleures pour les alertes et les tableaux de bord.

IsSuccessful

IsSuccessful vous permet de décider quelles erreurs comptent comme des échecs.

Ceci est important.

Toutes les erreurs ne devraient pas ouvrir le disjoncteur. Par exemple, un 404 Not Found d’un service utilisateur peut être un résultat métier valide. Un 400 Bad Request pourrait être la faute de l’appelant, pas de la dépendance.

Un 503 Service Unavailable, un délai d’expiration, une réinitialisation de connexion ou un 429 Too Many Requests peut être un vrai signal de santé de la dépendance.

Soyez prudent ici. Compter les mauvaises erreurs est l’un des moyens les plus simples de construire un disjoncteur bruyant.

Que devrait-on considérer comme une erreur ?

C’est là que le jugement d’ingénierie compte.

Comptez généralement ces éléments comme des échecs :

  • délais d’expiration réseau
  • connexion refusée
  • connexion réinitialisée
  • HTTP 500
  • HTTP 502
  • HTTP 503
  • HTTP 504
  • réponses 429 répétées
  • réponses mal formées de la dépendance
  • expiration du délai d’expiration du contexte pendant l’appel sortant

Ne comptez généralement pas ces éléments comme des échecs de dépendance :

  • erreurs de validation
  • erreurs de sérialisation locales
  • réponses 404 attendues
  • échecs d’autorisation côté appelant
  • rejets de règles métier
  • erreurs d’entrée utilisateur

Le disjoncteur devrait représenter la santé de la dépendance, pas l’échec général de l’application.

Disjoncteurs et context.Context

En Go, les disjoncteurs ne devraient pas remplacer context.Context.

Un disjoncteur décide s’il faut tenter un appel. Un contexte contrôle la durée pendant laquelle cet appel peut s’exécuter et s’il doit s’arrêter lorsque l’appelant est parti.

Un bon appel sortant devrait généralement avoir les deux :

ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()

data, err := client.GetUser(ctx, userID)

Le contexte devrait circuler à travers la chaîne d’appels :

contexte de la requête entrante
-> méthode de service
-> méthode client
-> requête HTTP
-> dépendance

Évitez de créer des contextes d’arrière-plan détachés dans le code scoped à la requête. Si la requête utilisateur est annulée, le travail en aval devrait généralement s’arrêter aussi.

La règle calme est :

Le disjoncteur protège le système. Le contexte protège la requête.

Vous avez normalement besoin des deux.

Disjoncteurs et reconnections

Les reconnections et les disjoncteurs peuvent bien fonctionner ensemble, mais l’ordre compte.

La valeur par défaut la plus sûre est :

délai d’expiration par tentative
reconnection avec backoff et jitter
disjoncteur autour de l’appel de dépendance

Mais il n’y a pas de réponse universelle. Réfléchissez à ce que vous voulez compter.

Si chaque tentative de reconnection passe par le disjoncteur, une requête utilisateur peut contribuer à plusieurs échecs. Cela peut ouvrir le disjoncteur plus rapidement, ce qui peut être bon ou mauvais.

Si le disjoncteur enveloppe l’opération de reconnection entière, le disjoncteur voit un seul succès ou échec final par requête utilisateur. C’est plus calme, mais cela peut masquer le nombre de tentatives échouées.

Pour de nombreux services d’application, cette forme est raisonnable :

requête utilisateur
-> disjoncteur
   -> politique de reconnection
      -> une tentative HTTP avec délai d’expiration

Cela signifie que le disjoncteur suit si l’opération de dépendance a finalement fonctionné pour l’appelant.

Pour les clients de plus bas niveau, cette forme peut également avoir du sens :

requête utilisateur
-> politique de reconnection
   -> disjoncteur
      -> une tentative HTTP avec délai d’expiration

Cela signifie que le disjoncteur protège chaque tentative.

La règle plus importante est celle-ci :

Ne réessayez pas aveuglément.

Utilisez :

  • un nombre maximum de reconnections petit
  • backoff exponentiel
  • jitter
  • délais d’expiration par tentative
  • un délai d’expiration global de la requête
  • idempotence pour les écritures
  • métriques pour les tentatives de reconnection

Sans cela, les reconnections peuvent transformer une petite panne en une plus grande. Pour un traitement plus approfondi de la sécurité des reconnections, voir Idempotence dans les systèmes distribués qui fonctionne réellement.

Disjoncteurs et idempotence

Les disjoncteurs apparaissent souvent à côté des reconnections, et les reconnections soulèvent la question de l’idempotence.

Pour les opérations de lecture, la reconnection est généralement sûre.

Pour les opérations d’écriture, la reconnection peut être dangereuse.

Considérez cet appel de paiement :

POST /charge

Si la requête expire, le paiement a-t-il échoué ? Peut-être. A-t-il réussi mais la réponse a-t-elle été perdue ? Peut-être aussi.

Si vous réessayez sans clé d’idempotence, vous pourriez facturer deux fois.

Pour les opérations d’écriture, utilisez une ou plusieurs de ces options :

  • clés d’idempotence
  • identifiants de requête
  • identifiants d’opération
  • contraintes uniques
  • boîtier de sortie transactionnel (transactional outbox)
  • orchestration de workflow
  • réconciliation explicite

Un disjoncteur peut vous empêcher de continuer à appeler un fournisseur de paiement défaillant, mais il ne peut pas rendre les reconnections non sûres.

Disjoncteurs et valeurs par défaut (Fallbacks)

Lorsque le circuit est ouvert, votre service a besoin d’un plan.

Les stratégies de repli possibles incluent :

  • retourner des données mises en cache
  • retourner des données périmées avec un avertissement
  • omettre une section non critique
  • mettre en file d’attente le travail pour plus tard
  • basculer vers un autre fournisseur
  • retourner une erreur temporaire
  • afficher une fonctionnalité dégradée
  • échouer la requête rapidement

Une valeur par défaut devrait être honnête.

Par exemple, c’est généralement bien :

{
  "status": "temporary_unavailable",
  "message": "Les recommandations sont temporairement indisponibles"
}

Ceci est risqué :

{
  "recommendations": []
}

Une liste vide peut ressembler à un résultat valide. Elle peut masquer une panne, confondre les utilisateurs et rendre le débogage plus difficile.

Les replis silencieux sont tentants. Ils sont également dangereux.

Disjoncteurs et observabilité

Un disjoncteur sans observabilité est principalement un générateur de surprises.

Suivez au moins ces métriques :

  • état actuel du disjoncteur
  • changements d’état
  • appels autorisés
  • appels rejetés
  • succès
  • échecs
  • délais d’expiration
  • réponses de repli
  • tentatives de reconnection
  • latence en aval
  • codes d’état en aval

Les étiquettes utiles incluent :

  • nom du disjoncteur
  • nom de la dépendance
  • nom de l’opération
  • classe de statut
  • catégorie d’erreur

Évitez les étiquettes à haute cardinalité telles que l’ID utilisateur, l’URL complète, l’ID de requête ou les messages d’erreur bruts.

Vous devriez pouvoir répondre à ces questions à partir des tableaux de bord :

  • Quels disjoncteurs sont ouverts en ce moment ?
  • À quelle fréquence s’ouvrent-ils ?
  • Quelle dépendance a causé l’ouverture ?
  • Les utilisateurs voient-ils des réponses de repli ?
  • La latence s’est-elle améliorée après l’ouverture du disjoncteur ?
  • Le volume de reconnection a-t-il augmenté avant l’ouverture du disjoncteur ?
  • La dépendance s’est-elle récupérée ?

Si vous ne pouvez pas observer le disjoncteur, vous ne pouvez pas l’ajuster. Pour une journalisation structurée qui fonctionne bien avec les métriques, voir Journalisation structurée en Go avec slog.

Une forme de client HTTP plus adaptée à la production

Pour les services réels, évitez de disperser la logique du disjoncteur dans les gestionnaires.

Créez un petit package client autour de la dépendance.

Exemple de structure :

internal/
  userservice/
    client.go
    errors.go
    metrics.go

Le gestionnaire ne devrait pas connaître les détails de gobreaker. Il devrait dépendre d’une méthode client de niveau domaine :

type UserService interface {
    GetUser(ctx context.Context, userID string) (*User, error)
}

Puis l’implémentation peut contenir :

  • création de la requête HTTP
  • propagation du contexte
  • exécution du disjoncteur
  • gestion du code d’état
  • décodage de la réponse
  • métriques
  • mappage d’erreur

Cela maintient la politique de résilience proche de la limite de la dépendance. Pour en savoir plus sur la classification des erreurs aux limites, voir Architecture de gestion des erreurs Go : Limites et modèles.

Où les disjoncteurs s’insèrent dans l’architecture d’application

Le modèle de disjoncteur appartient aux limites d’intégration.

Dans une application Go, cela signifie généralement :

graph LR A[Handler] --> B[Application Service] B --> C[Dependency Client] C --> D[Circuit Breaker] D --> E[HTTP / RPC / DB / Queue]

Gardez le disjoncteur hors de la logique métier autant que possible.

La couche métier devrait comprendre les erreurs de domaine comme :

fournisseur de paiement indisponible
recommandations indisponibles
délai d'expiration du service de profil

Il ne devrait pas avoir besoin de comprendre les états gobreaker.

Cette séparation maintient l’architecture propre :

  • les préoccupations de transport restent dans les clients
  • la politique de résilience reste près des dépendances
  • la logique métier reste lisible
  • les gestionnaires restent fins
  • les tests sont plus faciles à écrire

Cet article fait partie de la rubrique Architecture d’application en production — aux côtés de l’idempotence, de l’outbox, du saga et des guides d’orchestration dans les Modèles d’intégration.

Erreurs courantes

Erreur 1 : Pas de délai d’expiration

Un disjoncteur n’arrête pas magiquement les appels lents à moins que les appels ne retournent.

Si l’opération sortante peut se bloquer indéfiniment, le disjoncteur peut ne pas voir un échec assez rapidement.

Utilisez toujours des délais d’expiration.

Erreur 2 : Un disjoncteur global unique pour tout

N’utilisez pas un seul disjoncteur pour toutes les dépendances.

Un fournisseur d’e-mail défaillant ne devrait pas ouvrir le circuit pour votre fournisseur de paiement. Un point de terminaison de recherche lent ne devrait pas bloquer les appels de profil utilisateur.

Utilisez des disjoncteurs séparés pour des opérations de dépendance séparées lorsque leurs modes d’échec diffèrent.

Erreur 3 : Compter les erreurs de l’appelant comme des échecs de dépendance

Si votre service envoie de mauvaises entrées et reçoit 400 Bad Request, ce n’est généralement pas une panne en aval.

Ne formez pas le disjoncteur sur vos propres bugs.

Erreur 4 : Reconnecter des écritures non idempotentes

Les reconnections ne sont pas gratuites. Elles peuvent dupliquer des écritures, des paiements, des messages ou des effets secondaires.

Rendez les écritures idempotentes avant de les réessayer.

Erreur 5 : Masquer les pannes derrière des valeurs par défaut

Les valeurs par défaut devraient dégrader gracieusement, pas falsifier la réalité.

Si une dépendance est hors service, vos métriques et journaux devraient le rendre évident.

Erreur 6 : Ajuster sans données de production

Les seuils copiés à partir d’exemples ne sont que des points de départ.

Ajustez en fonction de :

  • volume de requêtes
  • taux d’erreur normal
  • latence de la dépendance
  • impact utilisateur
  • temps de récupération
  • qualité du repli

Erreur 7 : Utiliser des disjoncteurs au lieu de la gestion de la capacité

Un disjoncteur n’est pas un substitut pour :

  • rejet de charge (load shedding)
  • limitation de débit
  • limites de file d’attente
  • autoscaling
  • optimisation de la base de données
  • limites de pool de connexions
  • quotas en amont

C’est une partie d’une stratégie de résilience.

Valeurs par défaut pratiques

Pour un service Go typique appelant une dépendance HTTP interne, un point de départ raisonnable pourrait être :

délai d'expiration du client HTTP : 2 à 5 secondes
délai d'expiration du contexte par requête : basé sur le SLA de l'appelant
règle d'échec du disjoncteur : 5 échecs consécutifs ou 50 pour cent d'échec après 20 requêtes
délai d'expiration ouvert : 10 à 30 secondes
requêtes demi-ouvertes : 1 à 5
nombre de reconnections : 1 à 3 tentatives
backoff de reconnection : exponentiel avec jitter

Ce ne sont pas des valeurs universelles. Ce sont des points de départ assez sûrs.

Pour les API orientées utilisateur, maintenez les budgets de latence totale serrés. Pour les tâches en arrière-plan, vous pouvez tolérer des attentes plus longues. Pour les fournisseurs de paiement, soyez beaucoup plus prudent avec les reconnections et l’idempotence.

Liste de contrôle du disjoncteur

Avant d’ajouter un disjoncteur, répondez à ces questions :

  • Quelle dépendance est protégée ?
  • Quelle opération est protégée ?
  • Quelles erreurs comptent comme échec de dépendance ?
  • Quelles erreurs devraient être ignorées par le disjoncteur ?
  • Quel délai d’expiration s’applique à chaque appel ?
  • Les reconnections sont-elles autorisées ?
  • Les écritures sont-elles idempotentes ?
  • Que se passe-t-il lorsque le circuit est ouvert ?
  • Y a-t-il une valeur par défaut ?
  • La valeur par défaut est-elle visible dans les métriques ?
  • Qui est alerté si le circuit continue de s’ouvrir ?
  • Comment le disjoncteur sera-t-il ajusté après le déploiement ?

Si vous ne pouvez pas répondre à ces questions, l’ajout d’un disjoncteur peut créer plus de confusion que de résilience.

Tester les disjoncteurs en Go

Testez le comportement, pas la machine à états interne de la bibliothèque.

Les tests utiles incluent :

  • la dépendance réussit et la réponse est retournée
  • la dépendance échoue répétitivement et le circuit s’ouvre
  • le circuit ouvert retourne une erreur temporaire
  • les erreurs de validation côté client ne déclenchent pas le disjoncteur
  • le délai d’expiration du contexte est respecté
  • la réponse de repli est retournée comme attendu
  • les métriques sont émises lors des changements d’état

Utilisez de faux serveurs HTTP pour les tests de style intégration :

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()

Pour les tests unitaires, cachez la dépendance derrière une interface et injectez une implémentation factice.

Maintenez les tests déterministes. Évitez de dormir pendant de longues durées réelles. Configurez des délais d’expiration de disjoncteur courts dans les tests. Pour en savoir plus sur le test du code Go concurrent avec le temps factice et les bulles isolées, voir Tester le code Go concurrent avec testing/synctest.

Devriez-vous construire votre propre disjoncteur ?

Construire un petit disjoncteur est un bon exercice d’apprentissage. Cela vous aide à comprendre la machine à états.

Pour le code de production, privilégiez une bibliothèque maintenue à moins que vos besoins ne soient très spécifiques.

Un disjoncteur de production doit gérer :

  • la concurrence
  • les transitions d’état
  • les compteurs
  • les sondes demi-ouvertes
  • les rappels
  • la classification d’échec personnalisée
  • un comportement sans concurrence (race-free)
  • une gestion des erreurs prévisible

Ce n’est pas impossible, mais c’est facile à faire mal subtilement.

La bibliothèque ennuyeuse est généralement le meilleur choix.

Conclusion

Le modèle de disjoncteur n’est pas de la poussière de fiabilité magique.

En Go, il fonctionne mieux lorsqu’il fait partie d’une petite pile de résilience explicite :

délai d'expiration du contexte
+ reconnection avec backoff et jitter
+ disjoncteur
+ valeur par défaut
+ métriques

Le modèle est le plus utile aux limites des dépendances, en particulier autour des services distants qui peuvent devenir lents ou partiellement indisponibles.

Utilisez-le pour arrêter les pannes en cascade. Utilisez-le pour échouer rapidement lorsque une dépendance est clairement instable. Utilisez-le pour donner aux systèmes surchargés de la place pour se récupérer.

Mais ne l’utilisez pas comme une excuse pour ignorer les délais d’expiration, l’idempotence, l’observabilité ou une architecture propre.

Un bon disjoncteur rend l’échec plus clair et moins coûteux. Un mauvais rend l’échec plus mystérieux.

Références

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.