🎯 OBJECTIF
Comprendre comment :
🧠 MODÈLE MENTAL
Dans un système distribué, la question n'est pas si une dépendance va tomber, mais quand — et surtout : que fait votre service à ce moment-là ? Le scénario catastrophe n'est pas la panne franche (une erreur immédiate se gère bien), c'est la dépendance lente : chaque appel bloque un thread pendant 30 secondes, les threads s'accumulent, votre pool se vide, et votre service — pourtant sain — devient indisponible à son tour. C'est la panne en cascade : un service qui tombe en entraîne un autre, puis tout le système.
Resilience4j attaque ce problème avec une idée simple : plutôt qu'un framework lourd qui impose son modèle d'exécution (l'approche Hystrix), fournir une boîte à outils de décorateurs légers et composables. Chaque pattern (circuit breaker, retry, bulkhead…) est une fonction qui enveloppe votre appel, comme des poupées russes. Vous choisissez lesquels empiler, dans quel ordre, avec quelle configuration — et la librairie ne crée aucun thread par défaut : elle décore, elle n'exécute pas. L'intuition à retenir : échouer vite et de façon contrôlée vaut toujours mieux qu'attendre indéfiniment. Un fallback dégradé rendu en 5 ms protège mieux vos utilisateurs qu'une réponse parfaite qui n'arrive jamais.
Hystrix (Netflix) a popularisé le circuit breaker en Java, mais est en mode maintenance depuis 2018 — Netflix lui-même recommande Resilience4j. Les différences ne sont pas cosmétiques :
| Critère | Hystrix | Resilience4j |
|---|---|---|
| Modèle d'exécution | Thread pool imposé par commande (isolation par défaut) | Aucun thread créé — décorateurs sur l'appel existant |
| Style d'API | Héritage de classe (HystrixCommand) |
Fonctionnel — décore Supplier, Callable, CompletionStage |
| Dépendances | Archaius, RxJava… | Vavr uniquement (core), zéro dépendance transitive lourde |
| Granularité | Tout-en-un | Modules indépendants : on n'embarque que ce qu'on utilise |
| Fenêtre d'échec | Time-based uniquement | Count-based ou time-based |
| Statut | ⚰️ Maintenance depuis 2018 | ✅ Activement maintenu, intégré Spring Cloud Circuit Breaker |
Le cœur du design : chaque résilience-pattern est un décorateur d'ordre supérieur. Sans Spring, ça s'écrit littéralement comme une composition de fonctions :
Supplier<PaymentResult> decorated = Decorators
.ofSupplier(() -> paymentClient.charge(order))
.withCircuitBreaker(circuitBreaker) // ✓ opens on repeated failures
.withRetry(retry) // ✓ retries transient errors
.withBulkhead(bulkhead) // ✓ caps concurrent calls
.withFallback(List.of(CallNotPermittedException.class),
ex -> PaymentResult.deferred()) // ✓ graceful degradation
.decorate();
PaymentResult result = decorated.get(); // actual call happens herejava🔑 Conclusion clé
Resilience4j ne « fait » rien tant qu'on n'appelle pas la fonction décorée : c'est une composition paresseuse. Ce design sans thread pool le rend compatible avec n'importe quel modèle d'exécution — servlets classiques, CompletableFuture, et virtual threads.
Le circuit breaker est une machine à états qui observe les résultats des appels et coupe le circuit quand la dépendance va mal — exactement comme un disjoncteur électrique protège une installation.
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : taux d'échec ou de slow calls ≥ seuil
OPEN --> HALF_OPEN : après waitDurationInOpenState
HALF_OPEN --> CLOSED : les N appels d'essai réussissent
HALF_OPEN --> OPEN : taux d'échec des essais ≥ seuil
note right of OPEN
Rejet immédiat :
CallNotPermittedException
(aucun appel réseau)
end note
note right of HALF_OPEN
permittedNumberOfCallsInHalfOpenState
appels de test autorisés
end notemermaidCallNotPermittedException, sans toucher au réseau. C'est le « fail fast » : on ne gaspille ni thread ni latence sur une dépendance qu'on sait malade.resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20 # last 20 calls form the sample
minimumNumberOfCalls: 10 # ⚠️ no decision before 10 calls
failureRateThreshold: 50 # open if ≥ 50% failures
slowCallDurationThreshold: 2s # a call slower than 2s is "slow"
slowCallRateThreshold: 80 # open if ≥ 80% slow calls
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 5
recordExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.acme.payment.BusinessValidationException # ✓ not an outage signalyamlTrois subtilités qui font la différence en prod :
minimumNumberOfCalls — en dessous de ce volume, le breaker ne calcule rien. Valeur par défaut : 100. Sur un endpoint peu sollicité, un breaker avec les défauts peut ne jamais s'ouvrir.slowCallRateThreshold — le breaker peut s'ouvrir sur la lenteur seule, sans aucune exception. C'est souvent lui qui sauve le système : une dépendance qui répond en 28s « fonctionne » du point de vue des erreurs, mais elle est en train de vider votre pool de threads.ignoreExceptions — une erreur métier (validation refusée, solde insuffisant) n'est pas un signal de panne. La compter ferait s'ouvrir le circuit sur un comportement parfaitement normal.💥 Le circuit breaker n'est PAS un timeout
Un breaker en état CLOSED laisse passer l'appel sans limite de durée : si la dépendance met 90 secondes à répondre, votre thread attend 90 secondes. Le breaker compte les échecs a posteriori, il n'interrompt rien. Pour borner la durée d'un appel, il faut un TimeLimiter ou un timeout au niveau du client HTTP — toujours en complément du breaker, jamais l'un sans l'autre.
Une grande partie des erreurs en distribué sont transitoires : micro-coupure réseau, redéploiement d'un pod, deadlock ponctuel. Un simple réessai les absorbe. Mais un retry naïf est dangereux : réessayer immédiatement et en boucle, c'est multiplier la charge sur une dépendance déjà à genoux — le remède devient le poison (retry storm).
resilience4j:
retry:
instances:
paymentService:
maxAttempts: 3 # 1 initial call + 2 retries
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2 # 500ms → 1s → 2s
enableRandomizedWait: true # jitter: avoids thundering herd
randomizedWaitFactor: 0.5
retryExceptions:
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException # 5xx
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException # ⚠️ never retry 4xxyamlLes trois règles d'un retry sain :
💥 Retry + opération non idempotente = corruption
POST /payments qui échoue en timeout : le paiement a peut-être déjà été débité — le timeout ne dit pas si le serveur a traité la requête. Rejouer aveuglément peut débiter deux fois. Un retry n'est sûr que sur une opération idempotente : GET, PUT, DELETE, ou un POST protégé par une clé d'idempotence (Idempotency-Key). C'est le complément naturel du pattern Outbox côté écriture.
Le nom vient des cloisons étanches des navires : une voie d'eau inonde un compartiment, pas le bateau entier. Appliqué au backend : limiter le nombre d'appels concurrents vers chaque dépendance, pour qu'une dépendance lente ne puisse pas monopoliser toutes les ressources du service.
Sans bulkhead : le service d'export PDF (lent, non critique) reçoit un pic de trafic, ses appels s'accumulent et occupent les 200 threads du pool Tomcat → plus aucun thread pour le checkout (critique). Avec un bulkhead à 20 sur l'export : au pire, 20 threads y sont bloqués, les 180 autres continuent de servir le checkout.
Deux implémentations :
| ✅ Avantages | ❌ Inconvénients | |
|---|---|---|
| SemaphoreBulkhead (défaut) | Zéro overhead, s'exécute sur le thread appelant, compatible virtual threads | Ne borne pas la durée des appels en cours |
| FixedThreadPoolBulkhead | Isolation complète dans un pool dédié, retourne un CompletableFuture |
Coût des threads + context switching, réservé aux appels CompletionStage |
Quand le choisir : semaphore dans 95 % des cas — léger et suffisant. Thread pool uniquement si vous voulez une isolation stricte à la Hystrix pour une dépendance particulièrement instable, ou que l'appelant a besoin d'asynchronisme de toute façon.
resilience4j:
bulkhead:
instances:
pdfExport:
maxConcurrentCalls: 20
maxWaitDuration: 100ms # callers wait max 100ms for a permit, then BulkheadFullExceptionyaml🔑 Conclusion clé
Le bulkhead protège votre service de lui-même : il transforme « une dépendance lente épuise tout le pool » en « une dépendance lente sature son propre quota, le reste du service vit sa vie ». C'est le pattern le plus sous-utilisé de la famille, et souvent le plus rentable.
RateLimiter limite le débit (appels/période), là où le bulkhead limite la concurrence (appels simultanés). Usage typique : respecter le quota d'une API externe payante, ou protéger une dépendance fragile d'un pic de trafic.
resilience4j:
ratelimiter:
instances:
geocodingApi:
limitForPeriod: 50 # 50 calls...
limitRefreshPeriod: 1s # ...per second
timeoutDuration: 0 # don't wait for a permit: fail immediatelyyamlTimeLimiter borne la durée d'un appel asynchrone : au-delà du seuil, il lève TimeoutException et peut annuler le futur sous-jacent.
resilience4j:
timelimiter:
instances:
paymentService:
timeoutDuration: 3s
cancelRunningFuture: trueyaml⚠️ TimeLimiter exige de l'asynchrone
Le TimeLimiter ne fonctionne que sur un retour CompletionStage / CompletableFuture — il ne peut pas interrompre un appel bloquant synchrone. Pour du code bloquant classique, le timeout se règle au niveau du client HTTP (connect/read timeout de RestClient, WebClient, OpenFeign…). Règle pratique : toujours un timeout quelque part — un appel réseau sans timeout est un bug qui attend son heure.
C'est le point qui surprend tout le monde avec l'intégration Spring. Quand plusieurs annotations sont posées sur la même méthode, Resilience4j applique un ordre fixe (du plus externe au plus interne) :
flowchart LR
A[Appelant] --> R[Retry]
R --> CB[CircuitBreaker]
CB --> RL[RateLimiter]
RL --> TL[TimeLimiter]
TL --> B[Bulkhead]
B --> F["Méthode métier<br/>(appel réseau)"]mermaidRetry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( méthode ) ) ) ) )
Conséquences concrètes de cet ordre :
CallNotPermittedException → configurez le retry pour ignorer cette exception, sinon vous attendez maxAttempts × backoff pour rien alors que le breaker avait justement coupé pour échouer vite.resilience4j.<module>.<module>Aspect-order, mais le défaut est presque toujours le bon.Avec resilience4j-spring-boot3, tout se pilote par annotations + configuration YAML :
@Service
public class PaymentService {
private final PaymentClient paymentClient;
@CircuitBreaker(name = "paymentService", fallbackMethod = "chargeFallback")
@Retry(name = "paymentService")
@Bulkhead(name = "paymentService")
public PaymentResult charge(Order order) {
return paymentClient.charge(order); // remote call
}
// ⚠️ same return type + same params + exception as last arg
private PaymentResult chargeFallback(Order order, CallNotPermittedException ex) {
log.warn("Circuit open for paymentService, deferring order {}", order.id(), ex);
return PaymentResult.deferred(); // ✓ degraded but honest response
}
// ✓ several fallbacks can coexist: most specific exception type wins
private PaymentResult chargeFallback(Order order, Exception ex) {
log.error("Payment failed for order {}", order.id(), ex);
return PaymentResult.deferred();
}
}javaLes pièges classiques de cette intégration :
NoSuchMethodException au moment de l'échec, pas au démarrage.@Transactional, les annotations passent par un proxy Spring : un appel interne this.charge(order) contourne toute la protection. La méthode doit être appelée depuis un autre bean.Resilience4j expose ses métriques via Micrometer : état des breakers (resilience4j_circuitbreaker_state), taux d'échec, appels rejetés par bulkhead… Avec Actuator, /actuator/circuitbreakers et /actuator/circuitbreakerevents montrent l'état et l'historique des transitions. Un circuit breaker sans alerte sur le passage à OPEN ne sert qu'à moitié : le fallback masque la panne aux utilisateurs, l'alerte la révèle à l'équipe.
| Pattern | Protège contre | Limite quoi | Erreur émise | Quand l'utiliser |
|---|---|---|---|---|
| CircuitBreaker | Panne durable d'une dépendance | Rien — il observe et coupe | CallNotPermittedException |
Toujours, sur tout appel externe |
| Retry | Erreurs transitoires | Nombre de tentatives | Dernière exception rencontrée | Opérations idempotentes uniquement |
| Bulkhead | Épuisement des threads par une dépendance lente | Appels concurrents | BulkheadFullException |
Dépendances lentes ou non critiques |
| RateLimiter | Dépassement de quota, surcharge | Appels par période | RequestNotPermitted |
APIs externes à quota, protection d'une dépendance fragile |
| TimeLimiter | Appels qui ne répondent jamais | Durée d'un appel async | TimeoutException |
Appels CompletionStage ; sinon timeout du client HTTP |
⚡ TL;DR — chaque concept en une ligne
Circuit Breaker ✓ Machine à états (CLOSED → OPEN → HALF_OPEN) qui rejette immédiatement les appels quand le taux d'échec ou de slow calls dépasse un seuil sur la fenêtre glissante. ⚠ Ce n'est PAS un timeout : en état CLOSED, un appel peut bloquer indéfiniment — il faut un TimeLimiter ou un timeout client en complément.
Retry ✓ Absorbe les erreurs transitoires avec backoff exponentiel + jitter pour ne pas synchroniser les vagues de réessais. ⚠ Interdit sur les opérations non idempotentes sans clé d'idempotence, et inutile sur les erreurs 4xx — la réponse sera identique.
Bulkhead ✓ Plafonne les appels concurrents vers une dépendance pour qu'elle ne puisse pas vider le pool de threads du service entier. ⚠ La version sémaphore (défaut) ne borne pas la durée des appels déjà en cours — elle limite l'entrée, pas le séjour.
RateLimiter
✓ Plafonne le débit (N appels par période) — concurrence et débit sont deux dimensions différentes.
⚠ Avec timeoutDuration > 0 les appelants font la queue pour un permit : la latence ressentie explose avant que l'erreur n'apparaisse.
TimeLimiter
✓ Borne la durée d'un appel asynchrone et peut annuler le futur en cours.
⚠ Ne fonctionne que sur CompletionStage — pour du code bloquant, le timeout se règle dans le client HTTP.
Composition Spring
✓ Ordre fixe des décorateurs : Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( méthode ) ) ) ) ).
⚠ Chaque retry traverse le breaker — et si le breaker est OPEN, le retry réessaie des rejets instantanés sauf si CallNotPermittedException est ignorée.
🎓 À retenir
slowCallRateThreshold : une dépendance qui répond en 28 s passe sous les radars d'un breaker qui ne compte que les exceptions, alors qu'elle est en train de vider votre pool de threads.minimumNumberOfCalls vaut 100 par défaut — sur un endpoint à faible trafic, un breaker laissé aux valeurs par défaut peut ne jamais s'ouvrir ; dimensionnez la fenêtre par rapport au trafic réel.ignoreExceptions, une vague de validations refusées (comportement normal) peut ouvrir le circuit et couper un service parfaitement sain.@Transactional, un appel interne this.method() contourne silencieusement toutes les annotations de résilience.