Résilience applicative avec Resilience4j et Spring Boot

Publié le 04/02/2026 Source : sfeir.dev

Dans un monde où les applications sont de plus en plus distribuées et dépendantes de services externes, la résilience devient une qualité essentielle. Une API lente, un microservice indisponible ou une surcharge momentanée peuvent rapidement impacter l’expérience utilisateur.

Pour faire face à ces défis, les frameworks modernes proposent des solutions robustes permettant de limiter les défaillances en cascade et de protéger les systèmes critiques.

Deux approches complémentaires existent :

Dans cet article, nous allons nous concentrer sur Resilience4j et voir comment l’intégrer dans une application Spring Boot, avec des exemples concrets pour chacun de ses principaux modules.

Présentation de Resilience4j

Resilience4j est une bibliothèque légère de résilience inspirée de Hystrix (aujourd’hui obsolète). Elle fournit plusieurs patterns bien connus :

L’intégration avec Spring Boot est facilitée grâce aux annotations (@CircuitBreaker@Retry, etc.) et une configuration centralisée via application.properties.

⚖️ Avantages et inconvénients

➕ Avantages

➖ Inconvénients

Installation dans un projet Spring Boot

Pour ajouter Resilience4j à un projet Spring Boot 3, il suffit de déclarer la dépendance Maven suivante dans votre pom.xml :

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.3.0</version>
</dependency>

Puis, configurer les mécanismes désirés dans application.properties.
Exemple de configuration pour chaque module :

# CircuitBreaker
resilience4j.circuitbreaker.instances.backendA.slidingWindowSize=10
resilience4j.circuitbreaker.instances.backendA.failureRateThreshold=50
resilience4j.circuitbreaker.instances.backendA.waitDurationInOpenState=5s

# Retry
resilience4j.retry.instances.backendB.maxAttempts=3
resilience4j.retry.instances.backendB.waitDuration=1s

# RateLimiter
resilience4j.ratelimiter.instances.backendC.limitForPeriod=2
resilience4j.ratelimiter.instances.backendC.limitRefreshPeriod=10s

# Bulkhead
resilience4j.bulkhead.instances.backendD.maxConcurrentCalls=1

# TimeLimiter
resilience4j.timelimiter.instances.backendE.timeoutDuration=1s

Exemples pratiques

Nous allons maintenant parcourir chaque concept un par un.

Circuit Breaker

Le circuit breaker agit comme un fusible intelligent : il évite qu’un service indisponible ou trop lent ne dégrade toute l’application.

Lorsqu’un certain nombre d’appels échouent (erreurs ou timeouts), le circuit passe en OPEN et bloque immédiatement les appels suivants, renvoyant une réponse de repli (fallback).

@CircuitBreaker(name = "backendA", fallbackMethod = "fallback")
public String fetchData() {
    LOGGER.info("Attempting to fetch data...");
    // Simulate a failing service
    if (counter.incrementAndGet() % 3 != 0) {
        LOGGER.warn("Backend service is unavailable, throwing exception.");
        throw new RuntimeException("Backend service is unavailable");
    }
    LOGGER.info("Successfully fetched data from backend.");
    return "Data from backend";
}

public String fallback(RuntimeException t) {
    LOGGER.error("Fallback response due to: {}", t.getMessage());
    return "Fallback response: " + t.getMessage();
}

Ici, le service échoue deux fois sur trois.

Les état du Circuit Breaker

On peut comparer le CircuitBreaker à un interrupteur électrique :

Retry

Le retry permet de retenter automatiquement un appel qui échoue.

@Retry(name = "backendB", fallbackMethod = "retryFallback")
public String retryableFetchData() {
    LOGGER.info("Attempting to fetch data with retry... (attempt {})", retryCounter.get() + 1);
    // Simulate a service that fails twice then succeeds
    if (retryCounter.incrementAndGet() < 3) {
        LOGGER.warn("Retryable service is unavailable, throwing exception.");
        throw new RuntimeException("Retryable service is unavailable");
    }
    LOGGER.info("Successfully fetched data with retry.");
    retryCounter.set(0); // Reset for next independent call
    return "Data from retryable backend";
}

public String retryFallback(RuntimeException t) {
    LOGGER.error("Retry fallback response after all retries failed, due to: {}", t.getMessage());
    retryCounter.set(0); // Reset for next independent call
    return "Retry fallback response: " + t.getMessage();
}

Configuration : 3 tentatives avec 1 seconde d’attente entre chaque essai.

Rate Limiter

Le rate limiter limite le nombre d’appels autorisés dans une fenêtre de temps donnée.

@RateLimiter(name = "backendC", fallbackMethod = "rateLimiterFallback")
public String rateLimitedFetchData() {
    LOGGER.info("Attempting to fetch data with rate limiter...");
    return "Data from rate-limited backend";
}

public String rateLimiterFallback(io.github.resilience4j.ratelimiter.RequestNotPermitted t) {
    LOGGER.error("Rate limit exceeded, fallback response due to: {}", t.getMessage());
    return "Rate limit fallback response: " + t.getMessage();
}

Configuration : 2 appels toutes les 10 secondes.


Détour par Bucket4j

Si le Rate Limiter de Resilience4j est parfaitement adapté à des scénarios simples (protection d’un endpoint sensible, limitation d’accès utilisateur), il reste assez basique dans sa configuration.

Pour des cas d’usage plus avancés, on peut se tourner vers Bucket4j, une bibliothèque dédiée au rate limiting.

J’ai d’ailleurs écrit un article complet sur Bucket4j, disponible ici :

Limiter les appels à son API REST avec Bucket4j

Bulkhead

Le bulkhead isole les ressources pour empêcher la saturation globale.

@Bulkhead(name = "backendD", fallbackMethod = "bulkheadFallback")
public String bulkheadFetchData() throws InterruptedException {
    LOGGER.info("Attempting to fetch data with bulkhead...");
    Thread.sleep(3000); // Simulate a slow call
    LOGGER.info("Successfully fetched data with bulkhead.");
    return "Data from bulkhead backend";
}

public String bulkheadFallback(io.github.resilience4j.bulkhead.BulkheadFullException t) {
    LOGGER.error("Bulkhead is full, fallback response due to: {}", t.getMessage());
    return "Bulkhead fallback response: " + t.getMessage();
}

Configuration : un seul appel concurrent autorisé.

Time Limiter

Le time limiter interrompt un appel trop long.

@TimeLimiter(name = "backendE")
public CompletableFuture<String> timeLimitedFetchData() {
    return CompletableFuture.supplyAsync(() -> {
        LOGGER.info("Attempting to fetch data with time limiter...");
        try {
            Thread.sleep(3000); // Simulate a very slow call
        } catch (InterruptedException e) {
            // The future is cancelled, this exception is expected
            LOGGER.warn("Slow call was interrupted.");
            throw new RuntimeException(e);
        }
        LOGGER.info("This will not be logged due to the timeout.");
        return "Data from time-limited backend";
    });
}

Dans ce cas particulier j’ai préféré mettre le fallback directement dans le controller

@GetMapping("/timelimit-data")
public CompletableFuture<String> getTimeLimitedData() {
    return backendService.timeLimitedFetchData()
        .exceptionally(ex -> "Time limiter fallback response: " + ex.getMessage());
}

Mais on aurait très bien put avoir une méthode de fallback comme les autres exemple dans le service.

Configuration : délai maximum de 1 seconde.

Conclusion

Avec Resilience4j, il est simple de protéger son application contre les défaillances des services externes :

L’intégration avec Spring Boot est naturelle grâce aux annotations et à la configuration centralisée.

Bien configurés, ces mécanismes apportent une véritable robustesse aux applications distribuées. Ils doivent néanmoins être utilisés avec discernement pour éviter une complexité excessive ou des effets secondaires indésirables.