Développement Web

Resilience4j 3 avec Spring Boot 4 : circuit breaker, retry, bulkhead

10 دقائق للقراءة

Tout appel réseau peut échouer. En production, le défi n’est pas de prévenir tous les échecs — c’est impossible — mais d’éviter qu’un échec se propage et ne fasse tomber le système entier. Le pattern Circuit Breaker de Michael Nygard, combiné aux retries, timeouts, bulkheads et rate limiters, forme la boîte à outils standard. Resilience4j est la bibliothèque Java de référence pour ces patterns depuis le déclin de Hystrix (Netflix, fin de support 2020). Resilience4j 3 (sortie en 2025) nécessite Java 21 minimum. Au 18 mai 2026, le starter officiel resilience4j-spring-boot3 fonctionne avec Spring Boot 4 (Spring Framework 7) mais le support officiel SB4 reste discuté côté projet (issue ouverte fin 2025). En complément, Spring Boot 4 lui-même expose désormais @Retryable et @ConcurrencyLimit built-in via Spring Framework 7. Ce tutoriel reprend chaque pattern pas-à-pas avec configurations production-ready.

Prérequis

  • Java 21+ (idéalement Java 25 LTS pour virtual threads, cf. Virtual threads)
  • Spring Boot 4.0+ ou Spring Boot 3.5+
  • Compréhension des appels HTTP entre microservices
  • Optionnel : Prometheus + Grafana pour visualiser les métriques
  • Temps estimé : 90 minutes

Étape 1 — Ajouter Resilience4j à Spring Boot

Deux choix d’intégration. Le starter Spring Cloud Circuit Breaker (abstraction unifiée) ou les modules Resilience4j directs (API plus riche). Pour la flexibilité maximum, on installe les modules directs avec leurs intégrations Spring Boot.

<!-- pom.xml -->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-reactor</artifactId>
    <version>3.0.0</version>
</dependency>

# application.yml — config globale + override par instance
resilience4j:
  circuitbreaker:
    configs:
      default:
        register-health-indicator: true
        sliding-window-type: COUNT_BASED
        sliding-window-size: 100
        minimum-number-of-calls: 20
        failure-rate-threshold: 50
        slow-call-rate-threshold: 100
        slow-call-duration-threshold: 2s
        wait-duration-in-open-state: 30s
        permitted-number-of-calls-in-half-open-state: 5
        automatic-transition-from-open-to-half-open-enabled: true
    instances:
      catalogueService:
        base-config: default
      paiementService:
        base-config: default
        failure-rate-threshold: 30  # plus strict pour le paiement

Spring Boot 4 expose automatiquement les métriques via Actuator (/actuator/circuitbreakers) et health checks (/actuator/health). Chaque instance est nommée et configurable indépendamment, avec héritage depuis default pour éviter la duplication. Le mode COUNT_BASED (fenêtre des 100 derniers appels) est plus prévisible que TIME_BASED en charge variable.

Étape 2 — Annoter les méthodes avec @CircuitBreaker

L’usage Spring le plus simple : annoter une méthode appelant un service externe. Si le seuil d’échec est dépassé, le CB s’ouvre et les appels suivants échouent immédiatement (fail-fast) jusqu’à expiration du délai.

@Service
@RequiredArgsConstructor
public class CatalogueClient {
    private final RestClient restClient;

    @CircuitBreaker(name = "catalogueService", fallbackMethod = "obtenirProduitFallback")
    public Produit obtenirProduit(String id) {
        return restClient.get()
            .uri("/produits/{id}", id)
            .retrieve()
            .body(Produit.class);
    }

    // Méthode fallback : même signature + Throwable en dernier param
    public Produit obtenirProduitFallback(String id, Throwable ex) {
        log.warn("CB ouvert pour {} : {}", id, ex.getMessage());
        return ProduitCache.parDefaut(id);  // valeur cachée ou par défaut
    }
}

Trois disciplines critiques. Le fallback doit être rapide et déterministe (pas de second appel réseau qui pourrait aussi échouer). Le fallback ne doit pas masquer les erreurs côté utilisateur : journaliser, incrémenter un compteur Prometheus, alerter. Le nom du CB (catalogueService) doit correspondre au backend appelé pour que les statistiques soient interprétables.

Étape 3 — Comprendre les états du Circuit Breaker

Un Circuit Breaker traverse trois états principaux selon la machine d’état finie. Cette compréhension est essentielle pour configurer les seuils correctement.

# États du Circuit Breaker
#
# CLOSED (normal) : appels passent, on compte succès/échecs sur la fenêtre glissante
#   ↓ failure-rate-threshold dépassé
# OPEN (rejet) : tous les appels échouent immédiatement (fail-fast)
#   ↓ wait-duration-in-open-state écoulé (30s par défaut)
# HALF_OPEN (test) : N appels-test sont autorisés (5 par défaut)
#   ↓ majorité réussit ou échoue
# CLOSED ou OPEN
#
# États spéciaux :
# - DISABLED : CB désactivé (pour audit ou test)
# - FORCED_OPEN : forcé ouvert (incident manuel)
# - METRICS_ONLY : collecte sans bloquer (mesure avant activation)

Le passage automatic-transition-from-open-to-half-open-enabled bascule automatiquement OPEN → HALF_OPEN sans appel utilisateur — utile pour détecter rapidement la guérison. Sans cette option, le premier appel utilisateur après le délai déclenche la transition (latence ajoutée pour cet utilisateur). Pour des services à fort trafic, l’auto-transition est recommandée.

Étape 4 — Retry avec backoff exponentiel

Pour des erreurs transitoires (latence réseau, leader election en cours, redémarrage de pod), un retry suffit. Resilience4j Retry combine compteur, exclusions, et backoff exponentiel avec jitter.

resilience4j:
  retry:
    configs:
      default:
        max-attempts: 3
        wait-duration: 500ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        enable-randomized-wait: true
        randomized-wait-factor: 0.5
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException
          - java.net.SocketTimeoutException
          - java.util.concurrent.TimeoutException
        ignore-exceptions:
          - com.app.NotFoundException
          - com.app.ValidationException
    instances:
      catalogueService:
        base-config: default

@CircuitBreaker(name = "catalogueService")
@Retry(name = "catalogueService", fallbackMethod = "obtenirProduitFallback")
public Produit obtenirProduit(String id) { /* ... */ }

Le backoff exponentiel 500ms × 2^attempt avec jitter ±50 % donne : tentative 2 à ~500ms, tentative 3 à ~1s (avec randomisation). Cette discipline évite le thundering herd (tous les clients qui retry à la même milliseconde après une panne brève). Les ignore-exceptions empêchent de retry des erreurs métier permanentes (404, validation) qui ne se résoudront jamais.

Étape 5 — Timeout par appel

Un appel sans timeout peut traîner indéfiniment et bloquer un thread ou un connection pool. Resilience4j TimeLimiter fournit un timeout déclaratif applicable aux CompletableFuture ou aux types réactifs.

resilience4j:
  timelimiter:
    configs:
      default:
        timeout-duration: 3s
        cancel-running-future: true
    instances:
      catalogueService:
        base-config: default

@Service
public class CatalogueAsyncClient {
    @TimeLimiter(name = "catalogueService")
    @CircuitBreaker(name = "catalogueService", fallbackMethod = "fallback")
    public CompletableFuture<Produit> obtenirProduit(String id) {
        return CompletableFuture.supplyAsync(() -> restClient.get()
            .uri("/produits/{id}", id)
            .retrieve()
            .body(Produit.class));
    }

    public CompletableFuture<Produit> fallback(String id, Throwable ex) {
        return CompletableFuture.completedFuture(ProduitCache.parDefaut(id));
    }
}

Discipline timeout : la durée doit être inférieure au timeout HTTP du client ET au timeout du Circuit Breaker (slow-call-duration-threshold). Sinon, le TL annule en 3s mais le CB compte l’appel comme « slow » à 2s et n’incrémente jamais. Hiérarchie typique pour un service à latence p99 = 500ms : HTTP client 5s, CB slow threshold 2s, TL 3s.

Étape 6 — Bulkhead : isoler les pools de threads

Le pattern Bulkhead (cloison étanche) limite la concurrence sur un appel donné. Si le service catalogue répond lentement et qu’on a 100 threads bloqués dessus, le reste du système est asphyxié. Le bulkhead borne à 20 appels concurrents max sur ce service, les autres échouent vite (et déclenchent le fallback) plutôt que d’attendre.

resilience4j:
  bulkhead:
    configs:
      default:
        max-concurrent-calls: 25
        max-wait-duration: 100ms
    instances:
      catalogueService:
        base-config: default

  thread-pool-bulkhead:
    configs:
      default:
        max-thread-pool-size: 10
        core-thread-pool-size: 5
        queue-capacity: 20
        keep-alive-duration: 20ms
    instances:
      catalogueService:
        base-config: default

@Bulkhead(name = "catalogueService", type = Bulkhead.Type.SEMAPHORE)
@CircuitBreaker(name = "catalogueService", fallbackMethod = "fallback")
public Produit obtenirProduit(String id) { /* ... */ }

Deux types de bulkhead. SEMAPHORE (par défaut) : limite simple sur le compteur d’appels en cours, pas de thread pool dédié. THREADPOOL : pool dédié au service, plus puissant mais plus coûteux en mémoire. Pour la majorité des cas, semaphore suffit ; threadpool brille quand on veut isoler complètement (limites CPU différentes par service).

Étape 7 — Rate Limiter pour les API externes

Pour respecter les quotas des API tierces (Stripe : 100 req/s, Twilio : 50 req/s, etc.), on ajoute un Rate Limiter côté client. Différent du rate limiter côté serveur (Spring Cloud Gateway) qui limite les entrants ; ce rate limiter borne les sortants.

resilience4j:
  ratelimiter:
    configs:
      default:
        limit-for-period: 50  # 50 appels...
        limit-refresh-period: 1s  # ...par seconde
        timeout-duration: 200ms  # attente max si limit atteint
    instances:
      stripeApi:
        limit-for-period: 100
        limit-refresh-period: 1s
      twilioApi:
        limit-for-period: 50

@RateLimiter(name = "stripeApi")
@Retry(name = "stripeApi")
public PaiementResponse creerPaiement(PaiementRequest req) {
    return stripeClient.payments().create(req);
}

Quand la limite est atteinte, les appels supplémentaires attendent jusqu’à 200ms (timeout-duration). Au-delà, on lève RequestNotPermitted. Pour respecter le contrat Stripe en évitant les 429, cette discipline en amont évite la pénalité commerciale. Compromis : le timeout court entraîne quelques rejets côté applicatif sous pic, mieux vaut buffer dans Redis ou retry au niveau métier.

Étape 8 — Observabilité : metrics + alertes

Resilience4j expose toutes ses métriques via Micrometer → Prometheus. Les patterns sont dans /actuator/metrics et les health checks dans /actuator/health. La visualisation Grafana standard montre l’état CB, le taux d’échec, et les alertes sur transitions.

# Métriques exposées
# - resilience4j.circuitbreaker.state{name="catalogueService",state="open|closed|half_open"}
# - resilience4j.circuitbreaker.calls{name=...,kind="successful|failed|not_permitted|ignored"}
# - resilience4j.retry.calls{name=...,kind="successful|failed_with_retry|failed_without_retry"}
# - resilience4j.bulkhead.available.concurrent.calls{name=...}
# - resilience4j.ratelimiter.available.permissions{name=...}

# Alertes Prometheus
- alert: CircuitBreakerOpen
  expr: resilience4j_circuitbreaker_state{state="open"} == 1
  for: 1m
  labels: { severity: critical }
  annotations:
    summary: "CB {{ $labels.name }} ouvert depuis 1 minute"
    description: "Vérifier la santé du service en aval"

- alert: HighFailureRate
  expr: rate(resilience4j_circuitbreaker_calls{kind="failed"}[5m]) / rate(resilience4j_circuitbreaker_calls[5m]) > 0.1
  for: 5m
  labels: { severity: warning }

Le pattern d’alerte CircuitBreakerOpen est le canari : un CB ouvert > 1 minute signale un problème sous-jacent qui mérite une intervention humaine (le service appelé est down, ou notre seuil est trop sensible). Coupler à un événement Slack/PagerDuty pour réveiller l’équipe d’astreinte la nuit. La règle inverse — un CB qui n’ouvre jamais sur 30 jours — peut signaler que les seuils sont trop laxistes.

Erreurs fréquentes

Symptôme Cause Solution
CB toujours fermé malgré échecs visibles minimum-number-of-calls trop élevé Baisser à 5-10 pour services peu sollicités
CB s’ouvre sur 4xx légitimes recordExceptions trop large Ignorer NotFoundException, ValidationException, etc.
Retry sur POST non idempotent Configuration retry par défaut Désactiver retry sur POST/PUT sans idempotency-key
Fallback jamais appelé Exception levée par fallback aussi Fallback doit retourner valeur par défaut, jamais throw
Annotations Resilience4j ignorées Méthode privée ou self-invocation Méthode publique appelée depuis un autre bean
Mémoire qui croît avec ratelimiter Trop d’instances créées dynamiquement Réutiliser instances via Registry

Foire aux questions

Resilience4j ou Failsafe ?
Resilience4j est plus mature dans l’écosystème Spring (intégration native). Failsafe reste viable hors Spring, API plus simple.

Combien de CB pour un service ?
Un par dépendance externe (catalogue, paiement, notifications, …). Pas un par méthode. Granularité au niveau « service appelé », pas « endpoint individuel ».

Quels seuils de failure-rate ?
30 % pour les services critiques (paiement, auth). 50 % pour les services tolérants (analytics, recommandations). Adapter selon profil de trafic et coût d’un faux positif.

Compatible WebFlux / Reactor ?
Oui via resilience4j-reactor. Les opérateurs transformDeferred wrappent les Mono/Flux. Idem pour Kotlin coroutines avec resilience4j-kotlin.

Tester un Circuit Breaker ?
Injection d’un mock qui lève systématiquement, vérifier transition CLOSED → OPEN. CircuitBreakerRegistry permet d’instancier des CB jetables en test.

Pour aller plus loin

Le cluster Java Enterprise est désormais complet : virtual threads, native compilation, JPA avancé, gateway centralisée, résilience. Revenir au guide principal Java Enterprise moderne pour la vue d’ensemble.

Ressources et références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité