Go Programming

Construire des services Go résilients : Maîtriser les mécanismes de retry et les disjoncteurs

Dans le monde des systèmes distribués et des microservices, les pannes ne sont pas une question de si, mais de quand. Les partitions réseau, les timeouts de base de données et les pannes d'API tierces sont inévitables. Pour les développeurs Go, il est crucial de construire des services capables de gérer ces pannes transitoires avec élégance afin de maintenir la stabilité du système et la confiance des utilisateurs. Deux des modèles les plus efficaces pour atteindre cette résilience sont la Logique de Retry et le Modèle du Disjoncteur (Circuit Breaker). Dans cet article, nous explorerons comment implémenter ces modèles de manière robuste en Go.

La philosophie de la résilience

Lorsqu'un service appelle une dépendance externe — qu'il s'agisse d'une base de données, d'une file d'attente de messages ou d'un autre microservice — il existe toujours un risque de panne. Réessayer aveuglément chaque échec peut entraîner le problème de la « Troupeau Furieux » (Thundering Herd), où les services récupérés sont submergés par une inondation de requêtes. À l'inverse, échouer immédiatement au premier erreur réduit la disponibilité. L'objectif est de trouver un équilibre : réessayer suffisamment pour gérer les bugs transitoires, mais arrêter avant de provoquer des pannes en cascade.

Mise en œuvre de la logique de retry en Go

La logique de retry est simple en théorie mais nécessite une mise en œuvre prudente en pratique. Nous devons distinguer les opérations idempotentes des opérations non idempotentes. Réessayer une opération idempotente (comme une requête GET) est généralement sûr, tandis que réessayer une opération non idempotente (comme une requête POST) peut entraîner une duplication des données.

Pour cet exemple, nous allons implémenter une stratégie simple de retour exponentiel (exponential backoff) pour une requête HTTP idempotente. Le retour exponentiel augmente le temps d'attente entre les réessais, réduisant ainsi la charge sur le service défaillant.

package main

import (
	"context"
	"fmt"
	"math/rand"
	"time"
)

// RetryConfig contient les paramètres pour la logique de retry.
type RetryConfig struct {
	MaxRetries int
	BaseDelay  time.Duration
	MaxDelay   time.Duration
}

// RetryWithBackoff exécute une fonction avec un retour exponentiel.
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
	var err error
	delay := config.BaseDelay

	for attempt := 0; attempt <= config.MaxRetries; attempt++ {
		if attempt > 0 {
			// Calcul du prochain délai avec une variation (jitter) pour éviter la troupeau furieux
			jitter := time.Duration(rand.Int63n(int64(delay)))
			time.Sleep(delay + jitter)
			// Augmenter le délai pour l'itération suivante
			delay = delay * 2
			if delay > config.MaxDelay {
				delay = config.MaxDelay
			}
		}

		err = fn()
		if err == nil {
			return nil
		}

		// Optionnel : Vérifier si le contexte est terminé (ex : annulation par l'utilisateur)
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
	return fmt.Errorf("après %d tentatives, dernière erreur : %w", config.MaxRetries, err)
}

Dans le code ci-dessus, nous introduisons une variation (jitter) (aléatoire) dans le délai. Sans variation, si plusieurs services réessaient en même temps, ils pourraient tous attendre exactement la même durée et inonder le serveur simultanément. La variation aide à répartir les requêtes de retry.

Le modèle du Disjoncteur (Circuit Breaker)

Tandis que la logique de retry aide avec les erreurs transitoires, elle n'aide pas lorsqu'un service est complètement hors ligne ou échoue de manière constante. Dans de tels cas, continuer à envoyer des requêtes gaspille des ressources et augmente la latence. Le modèle du Disjoncteur résout ce problème en agissant comme un disjoncteur électrique : si trop d'échecs se produisent, il « saute » (trip) et arrête d'envoyer des requêtes pendant une période définie.

Un disjoncteur a généralement trois états :

  1. Fermé (Closed) : Les requêtes passent normalement. Si les échecs dépassent un seuil, le disjoncteur s'ouvre.
  2. Ouvert (Open) : Les requêtes échouent immédiatement sans appeler le service en aval. Cela empêche de surcharger davantage le service défaillant.
  3. Demi-ouvert (Half-Open) : Après une période de refroidissement, le disjoncteur autorise un nombre limité de requêtes de test à passer. Si elles réussissent, le disjoncteur se ferme ; si elles échouent, il se rouvre.

Dans les applications Go de production, il est fortement recommandé d'utiliser des bibliothèques éprouvées comme sony/gobreaker plutôt que d'écrire un automate d'états à partir de zéro, car ces bibliothèques gèrent efficacement la concurrence et les cas limites.

import (
	"github.com/sony/gobreaker"
	"net/http"
)

var settings = gobreaker.Settings{
	Name:    "ExternalAPICall",
	MaxRequests: 3,
	Interval:  60 * time.Second,
	Timeout:   30 * time.Second,
	ReadyToTrip: func(counts gobreaker.Counts) bool {
		return counts.ConsecutiveFailures >= 5
	},
}

var cb *gobreaker.TypedBreaker[http.Response]

func init() {
	cb = gobreaker.NewTypedBreaker[http.Response](settings)
}

func callExternalAPI(url string) (*http.Response, error) {
	req, _ := http.NewRequest("GET", url, nil)
	
	resp, err := cb.Execute(func() (interface{}, error) {
		return http.DefaultClient.Do(req)
	})
	
	if err != nil {
		// Gérer l'erreur du disjoncteur (requête rejetée)
		if err == gobreaker.ErrOpenState {
			return nil, fmt.Errorf("service temporairement indisponible")
		}
		return nil, err
	}
	
	return resp.(*http.Response), nil
}

Combinaison des retries et des disjoncteurs

Les architectures les plus robustes combinent les deux modèles. Cependant, l'ordre des opérations est important. En général, le Disjoncteur englobe la fonction, et la logique de Retry est appliquée à l'intérieur de la fonction appelable. Cela garantit que le disjoncteur saute en fonction des échecs réels plutôt que des échecs causés par un seul client qui réessaie de manière agressive.

Conclusion

Implémenter des mécanismes de retry et des disjoncteurs en Go est essentiel pour construire des microservices tolérants aux pannes. En utilisant un retour exponentiel avec variation pour les retries et en tirant parti de bibliothèques robustes comme gobreaker pour la gestion des disjoncteurs, les développeurs Go peuvent améliorer considérablement la résilience et la disponibilité de leurs applications. N'oubliez pas de toujours considérer l'idempotence de vos opérations et de surveiller les états de vos disjoncteurs pour maintenir un système distributé sain.

Share: