🎯 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. Concrètement, vous avez un appel réseau quelconque (ici paymentClient.charge(order)), et chaque pattern l'enveloppe d'une couche de protection supplémentaire — sans jamais toucher au code de l'appel lui-même :
// The remote call, unchanged. Each pattern wraps it like an onion layer.
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(); // nothing runs until this linejava🔑 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.
Pensez au disjoncteur électrique de votre maison : tant que tout va bien, le courant passe. En cas de court-circuit, il coupe — non pas pour vous embêter, mais pour éviter que le problème ne mette le feu à toute l'installation. Après réparation, vous le réarmez et le courant repasse.
Le circuit breaker logiciel fait exactement pareil avec les appels vers une dépendance. Déroulons un scénario concret avec cette configuration : fenêtre des 20 derniers appels, seuil d'échec 50 %, coupure de 30 s, 5 appels d'essai.
CLOSED (circuit fermé = le courant passe, comme en électricité). Chaque appel part réellement vers la dépendance, et le breaker note son résultat — succès ou échec — dans sa fenêtre des 20 derniers appels. Il ne bloque rien, il observe.OPEN (circuit ouvert = coupé).CallNotPermittedException — pas d'appel réseau, pas de thread bloqué, pas de timeout de 30 s à attendre. Votre service échoue en 0 ms et peut servir un fallback. Pendant ce temps, la dépendance respire au lieu de recevoir du trafic qu'elle ne peut pas traiter.HALF_OPEN (semi-ouvert). Il laisse passer exactement 5 appels réels, comme des éclaireurs.CLOSED, le trafic normal reprend. Ils échouent → retour à OPEN pour 30 s de plus, et on recommencera l'essai plus tard.Le diagramme reprend exactement ce scénario. Point de départ : la boîte 🚀 en haut — au démarrage, un breaker est toujours en CLOSED — puis suivez les flèches numérotées ① → ② → ③, qui correspondent aux étapes ci-dessus :
flowchart TD
START(["🚀 DÉPART — démarrage du service<br/>Le breaker commence toujours ici, en CLOSED."]) --> CLOSED
CLOSED["🟢 CLOSED — circuit fermé, état normal<br/>Les appels passent normalement.<br/>Le breaker compte succès et échecs<br/>sur les 20 derniers appels."]
OPEN["🔴 OPEN — circuit coupé<br/>Plus aucun appel ne part vers la dépendance.<br/>Rejet immédiat en 0 ms :<br/>CallNotPermittedException"]
HALFOPEN["🟡 HALF_OPEN — période d'essai<br/>5 appels réels sont laissés passer<br/>pour tester si la dépendance est guérie."]
CLOSED -- "① la dépendance tombe :<br/>12 échecs sur les 20 derniers appels<br/>(60 % ≥ seuil de 50 %)" --> OPEN
OPEN -- "② après 30 s d'attente<br/>(waitDurationInOpenState)" --> HALFOPEN
HALFOPEN -- "③✓ les 5 essais réussissent<br/>→ retour à la normale" --> CLOSED
HALFOPEN -- "③✗ les essais échouent encore<br/>→ on recoupe pour 30 s" --> OPENmermaidEn temps normal, le breaker passe sa vie dans la boîte verte CLOSED et n'en bouge jamais. Tout le reste du diagramme ne se déclenche que le jour où la dépendance tombe : coupure (①), attente (②), test de guérison (③) — et la boucle ③✗ → ② → ③ se répète tant que la dépendance n'est pas remise.
💡 Piège de vocabulaire
« Ouvert » = coupé, comme un circuit électrique : un breaker OPEN bloque les appels. C'est contre-intuitif si on pense « porte ouverte = on passe » — pensez interrupteur, pas porte.
// 1. Build the breaker with the scenario's configuration
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowSize(20) // sample = last 20 calls
.minimumNumberOfCalls(10) // no decision before 10 calls
.failureRateThreshold(50) // open at ≥ 50% failures
.slowCallDurationThreshold(Duration.ofSeconds(2)) // a call > 2s counts as "slow"
.slowCallRateThreshold(80) // open at ≥ 80% slow calls
.waitDurationInOpenState(Duration.ofSeconds(30))
.permittedNumberOfCallsInHalfOpenState(5)
.ignoreExceptions(BusinessValidationException.class) // business errors ≠ outage
.build();
CircuitBreaker breaker = CircuitBreaker.of("paymentService", config);
// 2. Watch state transitions — this is your alerting hook
breaker.getEventPublisher().onStateTransition(event ->
log.warn("Breaker '{}' transition: {}",
event.getCircuitBreakerName(), event.getStateTransition()));
// e.g. logs: "Breaker 'paymentService' transition: CLOSED_TO_OPEN"
// 3. Wrap the remote call
Supplier<PaymentResult> protectedCall =
CircuitBreaker.decorateSupplier(breaker, () -> paymentClient.charge(order));
// 4. Call it — and handle the "circuit is open" case explicitly
try {
return protectedCall.get(); // real network call (CLOSED or HALF_OPEN)
} catch (CallNotPermittedException e) {
// breaker is OPEN: NO network call happened, we failed in ~0 ms
return PaymentResult.deferred(); // ✓ fallback: degraded but instant answer
}javaLa même configuration en YAML pour l'intégration Spring Boot (section 8️⃣) :
resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallDurationThreshold: 2s
slowCallRateThreshold: 80
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 5
ignoreExceptions:
- com.acme.payment.BusinessValidationExceptionyamlTrois 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 28 s « 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).
RetryConfig config = RetryConfig.custom()
.maxAttempts(3) // 1 initial call + 2 retries
// exponential backoff WITH jitter: ~500ms, ~1s, ~2s (each ± random factor)
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
Duration.ofMillis(500), // initial wait
2.0, // multiplier
0.5)) // jitter factor: ±50% randomness
.retryOnException(ex -> ex instanceof IOException
|| ex instanceof HttpServerErrorException) // ✓ retry 5xx
.ignoreExceptions(HttpClientErrorException.class) // ⚠️ never retry 4xx
.build();
Retry retry = Retry.of("paymentService", config);
// Each attempt is observable — plug your metrics here
retry.getEventPublisher().onRetry(event ->
log.info("Attempt #{} failed with {}, retrying...",
event.getNumberOfRetryAttempts(), event.getLastThrowable().toString()));
Supplier<PaymentResult> withRetry =
Retry.decorateSupplier(retry, () -> paymentClient.charge(order));
// Runs up to 3 attempts. If all fail, the LAST exception is rethrown.
PaymentResult result = withRetry.get();javaÉquivalent YAML pour Spring Boot :
resilience4j:
retry:
instances:
paymentService:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions:
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorExceptionyamlLes 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.
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(20) // at most 20 calls in flight
.maxWaitDuration(Duration.ofMillis(100)) // wait max 100ms for a free slot
.build();
Bulkhead bulkhead = Bulkhead.of("pdfExport", config);
Supplier<byte[]> protectedExport =
Bulkhead.decorateSupplier(bulkhead, () -> pdfClient.render(report));
try {
return protectedExport.get(); // acquires a slot, runs, releases the slot
} catch (BulkheadFullException e) {
// 20 calls already in flight and no slot freed within 100ms:
// the 21st caller is rejected — the rest of the service keeps its threads
throw new ServiceBusyException("PDF export saturated, retry later");
}javaresilience4j:
bulkhead:
instances:
pdfExport:
maxConcurrentCalls: 20
maxWaitDuration: 100msyamlDeux 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.
🔑 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). La nuance : 50 appels/seconde qui durent chacun 10 ms ne font que ~0,5 appel concurrent en moyenne — débit élevé, concurrence faible. Usage typique du rate limiter : respecter le quota d'une API externe payante, ou protéger une dépendance fragile d'un pic de trafic.
RateLimiterConfig config = RateLimiterConfig.custom()
.limitForPeriod(50) // 50 calls...
.limitRefreshPeriod(Duration.ofSeconds(1)) // ...per second
.timeoutDuration(Duration.ZERO) // no queueing: reject immediately
.build();
RateLimiter limiter = RateLimiter.of("geocodingApi", config);
Supplier<Coordinates> limited =
RateLimiter.decorateSupplier(limiter, () -> geoClient.geocode(address));
try {
return limited.get();
} catch (RequestNotPermitted e) {
// quota of 50 calls this second is exhausted
return cachedCoordinates(address); // ✓ fallback: last known position
}javaTimeLimiter borne la durée d'un appel asynchrone : au-delà du seuil, il complète le futur avec une TimeoutException et peut annuler la tâche sous-jacente.
TimeLimiter timeLimiter = TimeLimiter.of("paymentService",
TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(3))
.cancelRunningFuture(true) // also cancel the underlying task
.build());
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
// ⚠️ the protected call MUST return a CompletableFuture / CompletionStage
CompletableFuture<PaymentResult> future = timeLimiter.executeCompletionStage(
scheduler,
() -> CompletableFuture.supplyAsync(() -> paymentClient.charge(order)))
.toCompletableFuture();
// If the dependency hangs, after 3s the future completes
// exceptionally with TimeoutException — the caller is never stuck forever.javaÉquivalents YAML :
resilience4j:
ratelimiter:
instances:
geocodingApi:
limitForPeriod: 50
limitRefreshPeriod: 1s
timeoutDuration: 0
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, plus besoin de construire les objets à la main comme dans les sections précédentes : tout se pilote par annotations, et la configuration vient du YAML (les blocs instances.paymentService vus plus haut) :
@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.