Le modèle de nouvelle tentative : créer des microservices résilients

Le modèle de nouvelle tentative : créer des microservices résilients

Dans une architecture de microservices, les services communiquent via un réseau plutôt que via des appels en mémoire. Bien que ce découplage permette une mise à l’échelle horizontale massive et des déploiements indépendants, il introduit également une vulnérabilité majeure : le réseau n’est pas fiable.

À tout moment, un service en aval peut rencontrer un bref problème de réseau, un pic temporaire du processeur, un conflit de verrouillage rapide de la base de données ou un redémarrage de mise à jour continue. Ces échecs temporaires sont appelés défauts transitoires.

Si votre service génère immédiatement une erreur et échoue à la demande dès qu’un appel en aval échoue, vous créez une expérience utilisateur fragile. Au lieu de cela, bon nombre de ces erreurs transitoires peuvent être résolues automatiquement en attendant un moment et en réessayant. C’est là qu’intervient le modèle de nouvelle tentative.

Dans ce guide, nous explorerons le modèle Retry, son fonctionnement sous le capot, les dangers des implémentations naïves et comment l’implémenter correctement en Java (Resilience4j) et Go.


L’analogie avec le monde réel : recomposer une ligne occupée

Imaginez que vous essayez d’appeler un ami. Vous composez leur numéro, mais vous recevez une tonalité occupée car ils sont actuellement en communication.

Abandonnez-vous immédiatement, supprimez-vous leur contact et supposez-vous que vous ne pourrez plus jamais leur parler ? Bien sûr que non. Vous raccrochez, attendez une minute et composez à nouveau leur numéro. S’ils sont toujours occupés, vous pouvez attendre cinq minutes avant de réessayer.

Finalement, leur appel se termine et votre nouvelle tentative réussit.

Diagramme d'architecture de modèle de nouvelle tentative montrant la tentative de demande, le délai d'attente et la deuxième tentative réussie

Dans les microservices :

  • L’appel est une requête API adressée à un service en aval.
  • Le signal occupé est une erreur réseau transitoire ou une réponse 503 Service Unavailable.
  • La recomposition est une nouvelle tentative.
  • Le temps d’attente est la durée d’attente.

Le danger : tentatives naïves et “réessayer les tempêtes”

Implémenter un mécanisme de nouvelle tentative semble trivial à première vue : enveloppez simplement votre appel HTTP dans une boucle for et continuez à essayer jusqu’à ce qu’il réussisse. Cependant, une nouvelle tentative naïve peut facilement transformer un problème mineur en une panne catastrophique à l’échelle du système.

Imaginez un service en aval qui se débat avec une soudaine augmentation du trafic. Sa base de données fonctionne à 99 % d’utilisation du processeur et les requêtes commencent à expirer.

Si 100 services clients détectent tous un délai d’attente et réessayent immédiatement 3 fois sans attendre, ils tripleront soudainement le volume de trafic frappant le service en aval déjà étranglé. Cette amplification soudaine du trafic est connue sous le nom de Retry Storm (ou problème Thundering Herd).

Au lieu d’aider le service en aval à récupérer, vos tentatives continueront à le faire baisser, l’empêchant de rattraper son retard.


La solution : recul et gigue

Pour éviter les tempêtes de nouvelles tentatives, un système résilient doit utiliser deux stratégies essentielles : Backoff et Gigue.

1. Stratégies d’attente

L’intervalle d’attente indique combien de temps un client doit attendre avant d’effectuer une nouvelle tentative.

  • Arrêt fixe : le client attend un temps constant (par exemple, exactement 200 ms) entre les tentatives. Bien que simple, cette solution comporte toujours le risque de pics de trafic synchronisés.
  • Exponential Backoff : le temps d’attente augmente de façon exponentielle à chaque tentative échouée (par exemple, 100 ms, 200 ms, 400 ms, 800 ms). Cela donne progressivement au service en aval plus de temps pour récupérer à mesure que la panne persiste.

2. Gigue (aléatoire)

Même avec une interruption exponentielle, si un incident sur le réseau provoque l’échec de 1 000 requêtes exactement au même instant, les 1 000 clients calculeront exactement le même délai d’attente. Par conséquent, ils réessayeront tous simultanément par vagues, atteignant le service en aval avec des pics synchronisés.

Jitter résout ce problème en ajoutant une variance aléatoire au délai d’attente.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

En répartissant les tentatives sur un intervalle aléatoire, la charge sur le service en aval est lissée, lui permettant ainsi de récupérer en douceur.


La règle d’or des tentatives : l’idempotence

Avant d’appliquer des tentatives à un point de terminaison d’API, vous devez vous poser une question essentielle : Cette opération est-elle idempotente ?

Une opération idempotente est une opération dans laquelle effectuer plusieurs requêtes identiques a exactement le même effet que faire une seule requête.

  • Idempotent : Lecture d’un profil utilisateur (GET /users/123), mise à jour d’un champ email entier (PUT /users/123/email) ou suppression d’un élément (DELETE /items/456).
  • Non idempotent : création d’une nouvelle commande (POST /orders) ou traitement d’un débit de carte de crédit (POST /payments).

Supposons que vous appeliez POST /payments pour facturer 50 $ à un client. La passerelle de paiement en aval reçoit la demande, débite la carte de crédit avec succès, mais un problème de réseau se produit avant de pouvoir vous renvoyer la réponse 200 OK.

Votre service enregistre un délai d’attente, suppose que l’appel a échoué et réessaye automatiquement. Si la passerelle de paiement n’est pas conçue pour gérer les demandes en double, le client sera facturé deux fois.

[!AVERTISSEMENT] Ne réessayez jamais d’opérations non idempotentes à moins que le service en aval ne prenne en charge les clés d’idempotence (identifiants de requête uniques utilisés pour détecter et éliminer les transactions en double).


Exemples de mise en œuvre

Voyons comment implémenter le modèle de tentative dans Java et Go.

1. Java (Resilience4j et Spring Boot)

Resilience4j fournit un module de nouvelle tentative robuste et hautement configurable. Voici comment le configurer pour un service de facturation externe.

Configuration (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

Implémentation du code

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. Allez (Golang)

Dans Go, nous pouvons écrire un exécuteur de nouvelle tentative élégant avec un recul exponentiel et une gigue aléatoire à l’aide de minuteries de bibliothèque standard.

package main

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

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

Réessayer ou disjoncteur : quand utiliser lequel ?

Les développeurs confondent souvent le modèle de nouvelle tentative avec le modèle de disjoncteur. Bien que les deux visent à améliorer la résilience du système, ils gèrent des types de pannes fondamentalement différents :

Métrique Réessayer le modèle Modèle de disjoncteur
Objectif principal Récupère automatiquement après des pannes transitoires (de courte durée). Empêche les échecs persistants (de longue durée) de supprimer l’appelant.
Stratégie Réessayez immédiatement ou après une courte attente. Échec rapide immédiatement sans appeler le service cible.
Impact en aval Augmente temporairement la charge sur le service en aval. Protège le service en aval du trafic, lui permettant de récupérer.
Déclencheurs typiques Brèves déconnexions du réseau, délais d’attente de la base de données, suppressions de sockets. Le service en aval est complètement en panne, renvoyant des 500 secondes ou des délais d’attente continus.

Le couple puissant : combiner les deux

Dans les systèmes de production, ces deux modèles sont conçus pour être utilisés ensemble.

Lorsque vous effectuez une requête en aval, elle doit d’abord passer par le Circuit Breaker, puis par le wrapper Retry.

Si un problème transitoire se produit, le mécanisme Retry l’intercepte et réessaye. Cependant, si le service en aval tombe en panne, les pannes s’accumuleront. Le disjoncteur détecte les pannes consécutives, « se déclenche » et bloque tous les appels futurs.

Désormais, lorsque vous essayez d’appeler le service, le disjoncteur tombe en panne rapidement et immédiatement, contournant entièrement la boucle de nouvelle tentative et économisant ainsi vos ressources informatiques.


Meilleures pratiques pour le modèle de nouvelle tentative

  1. Réessayer uniquement les erreurs transitoires : Inspectez les codes d’état HTTP. Réessayez 503 Service Unavailable, 429 Too Many Requests (en respectant les en-têtes Retry-After s’ils sont présents) et les délais d’attente du réseau. Ne réessayez PAS 400 Bad Request, 401 Unauthorized ou 404 Not Found : ils ne réussiront jamais lors d’une nouvelle tentative.
  2. Appliquer la gigue : n’utilisez jamais de minuterie de nouvelle tentative fixe sans introduire de gigue aléatoire.
  3. Limiter le nombre maximum de tentatives : arrêtez de réessayer après un seuil raisonnable (généralement 3 à 5 tentatives). Les tentatives illimitées consomment des ressources et réduisent la latence.
  4. Soyez prudent avec les tentatives en cascade : Si le service A appelle le service B (qui réessaye 3 fois) et que le service B appelle le service C (qui réessaye 3 fois), un échec dans C peut entraîner 3 $ \times 3 = 9$ d’appels en cascade. N’appliquez les tentatives qu’aux limites où elles ont le plus de sens.
  5. Toujours définir des délais d’attente stricts : assurez-vous que les délais d’attente de votre réseau sont plus courts que vos périodes d’attente, afin que les threads ne soient pas conservés indéfiniment.

Conclusion

Le Retry Pattern constitue une puissante première ligne de défense contre le manque de fiabilité du réseau dans les architectures de microservices. Lorsqu’il est associé à Exponential Backoff, Jitter et à un Circuit Breaker, vous pouvez protéger vos systèmes contre les pannes en cascade et créer une expérience transparente et d’auto-réparation pour vos utilisateurs.