Skip to main content
David Le Montagner Home Page David Le Montagner

Renforcer la résilience grâce au pattern Circuit Breaker

Introduction #

Aujourd'hui, je vais vous parler d'un pattern indispensable pour les applications s'appuyant massivement sur des services externes type API: le circuit breaker. Le sujet est plus léger que celui de mes derniers articles mais je me suis rendu compte que la pratique n'était pas toujours systématique. J'espère vous convaincre de l'utilité du pattern ainsi que de la facilité avec laquelle il est possible de le mettre en place 😉

Qu’est-ce qu’un circuit breaker? #

Popularisé par Netflix à travers leur bibliothèque Hystrix, le circuit breaker est un design pattern inspiré du monde réel. De la même manière qu’un disjoncteur électrique protège un circuit contre les surtensions en interrompant le flux électrique, un circuit breaker dans le monde du logiciel prévient les défaillances en évitant d'appeler un service qui présente des signes de dysfonctionnement.

Pourquoi c’est (très) important? #

Dans le contexte des microservices, lorsque vous avez de nombreux services qui interagissent entre eux, la défaillance d'un seul service peut avoir des effets en cascade sur d'autres services, conduisant à une panne ou à des dégradations majeures de la performance sur l’ensemble du périmètre. Le circuit breaker est conçu pour prévenir ce genre de scénarios qui ne manquera pas de survenir un jour sur toute application distribuée 😉

Comment ça marche? #

Le circuit breaker surveille tous les appels à un service donné. Si le nombre d'échecs dépasse un seuil défini sur une période donnée, le circuit breaker "s'ouvre" et commence à bloquer toutes les demandes vers ce service pendant une certaine période paramétrable. Cela donne au service en question le temps de se rétablir, sans être submergé par de nouvelles requêtes. Pendant cette période, le circuit breaker se rabat sur une solution dégradée (réponse par défaut, récupération dans un cache etc…)

Après cette période, le circuit breaker entre dans un état "semi-ouvert", où il laisse passer quelques requêtes pour vérifier si le service est à nouveau sain. Si les appels réussissent, il "ferme" le circuit et permet de nouveau le flux des requêtes. Sinon, il revient à l'état ouvert pour une autre période d’attente.

Workflow d'un circuit breaker

Par la suite, nous allons illustrer ce mécanisme avec un service réalisant un appel vers une API REST mais notez bien qu'il est tout à fait possible d'appliquer ce principe sur d'autres types de services.

Exemple avec Spring Boot & Resilience 4J #

J’ai décidé d’utiliser Spring Boot et Resilience4J pour illustrer la fonctionnalité. Dans l’univers Java, il est tout à fait possible de réaliser la même chose avec Quarkus via la librairie SmallRye Fault Tolerance.

Pour cet exemple, nous allons créer deux services:

Tester la fonctionnalité #

Avant d’implémenter une fonctionnalité, il est bien évidemment indispensable d’écrire le test validant celle-ci 😉

Bien que Resilience4J propose un nombre important de fonctionnalités supplémentaires, nous allons illustrer sa fonctionnalité “principale” de circuit breaker c’est à dire la capacité à arrêter les appels vers un service extérieur suite à son indisponibilité et à se rabattre sur une solution alternative.

Pour être efficace, le test ne peut pas dépendre d’un service externe “réel”. En effet, il faudrait qu’il soit capable de tomber en panne à la demande lors des tests. Ainsi, nous allons mocker un serveur afin de pouvoir simuler les réponses de ce dernier mais également ses indisponibilités.

Pour ce faire, nous allons utiliser Wiremock via la dépendance Spring Cloud. Spring Cloud propose une annotation AutoConfigureWireMock qui nous fera gagner un peu de temps:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
</dependency>

Notre test va se dérouler en 20 itérations qui se définissent ainsi:

Itération 1 Itération 2 à 11 Itération 11 à 14 Itération 14 à 19 Itération 19 à 20
Etat Service HelloWorld OK PANNE OK OK OK
Etat Circuit Breaker FERME FERME OUVERT SEMI OUVERT (Délai d’attente de 5 secondes au début de l’itération 14) FERME
Requêtes attendues vers le service HelloWorld? OUI OUI NON OUI OUI
Resultat attendu Hello World! (HW) Hello Fallback! (HF) HF HW HW

Nous allons ainsi démontrer que:

Voici l’implémentation que je propose pour ce scénario:

@SpringBootTest(
    webEnvironment = RANDOM_PORT,
    properties = {
            "helloWorld.base-uri=http://localhost:${wiremock.server.port}"
    }
)
@AutoConfigureWireMock(port = 0)
public class CircuitBreakerDemoClientServiceTests {
    private static final int HELLOWORLD_SERVICE_OUTAGE_START = 2;
    private static final int HELLOWORLD_SERVICE_OUTAGE_END = 11;
    private static final int CIRCUITBREAKER_OPEN_START = 11;
    private static final int CIRCUITBREAKER_OPEN_END = 13;
    private static final int HALF_OPEN_STATE_START = 14;
    private static final int HALF_OPEN_STATE_WAIT_DURATION = 5;
    private static final String HELLOWORLD_URL_PATH = "/";
    private static final String HELLOWORLD_SUCCESSFUL_RESULT = "Hello World!";
    private static final String HELLOWORLD_FALLBACK_RESULT = "Hello Fallback!";

    @Autowired
    CircuitBreakerDemoClientService circuitBreakerDemoClientService;

    @Autowired
    private WireMockServer HELLO_WORLD_MOCKED_SERVICE;

    @RepeatedTest(20)
    public void testCircuitBreaker(RepetitionInfo repetitionInfo) throws InterruptedException {
        int currentRepetition = repetitionInfo.getCurrentRepetition();

        waitForHalfOpenStateIfNeeded(currentRepetition);
        mockServiceBasedOnIteration(currentRepetition);

        String expectedHelloWorldResult = getExpectedHelloWorldResult(currentRepetition);
        int expectedRequestCount = getExpectedRequestCount(currentRepetition);

        String helloWorldResult = circuitBreakerDemoClientService.getHelloWorld().block();

        assertThat(helloWorldResult).isEqualTo(expectedHelloWorldResult);
        HELLO_WORLD_MOCKED_SERVICE.verify(expectedRequestCount, getRequestedFor(urlEqualTo(HELLOWORLD_URL_PATH)));
    }

    private void waitForHalfOpenStateIfNeeded(int currentRepetition) throws InterruptedException {
        // Before starting iteration 14, wait 5 seconds
        // So that circuit breaker has time to switch to half open state
        if(currentRepetition == HALF_OPEN_STATE_START)
            TimeUnit.SECONDS.sleep(HALF_OPEN_STATE_WAIT_DURATION);
    }

    private void mockServiceBasedOnIteration(int currentRepetition) {
        HELLO_WORLD_MOCKED_SERVICE.resetAll();

        // For iterations between 2 and 11 included, helloWorld service is not working
        if(currentRepetition >= HELLOWORLD_SERVICE_OUTAGE_START && currentRepetition <= HELLOWORLD_SERVICE_OUTAGE_END)
            HELLO_WORLD_MOCKED_SERVICE.stubFor(WireMock.get(HELLOWORLD_URL_PATH)
                    .willReturn(serverError()));
        else
            // First iteration and after iteration 12, helloWorld service is working
            HELLO_WORLD_MOCKED_SERVICE.stubFor(WireMock.get(HELLOWORLD_URL_PATH)
                    .willReturn(aResponse()
                            .withBody(HELLOWORLD_SUCCESSFUL_RESULT)));
    }

    private String getExpectedHelloWorldResult(int currentRepetition) {
        // Fallback response is expected when service is not working (iterations 2 to 11)
        // or circuit Breaker is open (iterations 11, 12, 13)
        if(currentRepetition >= HELLOWORLD_SERVICE_OUTAGE_START && currentRepetition <= CIRCUITBREAKER_OPEN_END)
            return HELLOWORLD_FALLBACK_RESULT;

        return HELLOWORLD_SUCCESSFUL_RESULT;
    }

    private int getExpectedRequestCount(int currentRepetition) {
        // After 10 calls, if failure rate is above 50%, circuit breaker is triggered
        // Meaning the requests should NOT reach the hello world service
        // As we'll wait 5 seconds between repetition 13 & 14,
        // requests should reach back hello world service after repetition 13
        if(currentRepetition >= CIRCUITBREAKER_OPEN_START && currentRepetition <= CIRCUITBREAKER_OPEN_END)
            return 0;

        return 1;
    }

}

Nous nous limitons ici à simuler des erreurs mais sachez que WireMock est capable de simuler énormément de situations telles que des lenteurs, des stubs différents en fonction de différents critères, etc… Il intègre également des outils pour faciliter la création de stubs tel que WireMock Recorder. Si vous travaillez avec des API externes, c’est un outil indispensable pour écrire vos tests.

Implémenter la fonctionnalité #

Je vous épargne la partie serveur “Hello World!” qui n’est pas celle qui nous intéresse aujourd’hui mais, si besoin, vous y avez évidemment accès sur le repository du code correspondant à cet article. Nous allons désormais nous concentrer sur l’appel au service externe et la mise en place du circuit breaker dans la partie client.

Pour commencer, il faut ajouter les dépendances en prenant soin de choisir la bonne en fonction du fait que l’on soit ou non en mode reactive. Dans mon exemple, je vais utiliser les librairies reactives mais, à toute fin utile, j’indique la librairie non reactive en commentaire:

<!-- For annotations usage -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- Dependency for non reactive mode
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
-->

<!-- Dependency for reactive mode -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>

Ensuite, il faut:

@Service
public class CircuitBreakerDemoClientService {
    private final WebClient webClient;
    private final static String HELLOWORLD_PATH = "/";
    private final static String HELLOWORLD_FALLBACK_RESULT = "Hello Fallback!";

    public CircuitBreakerDemoClientService(WebClient.Builder webClientBuilder, @Value("${helloWorld.base-uri}") String helloWorldBaseUri) {
        this.webClient = webClientBuilder.baseUrl(helloWorldBaseUri).build();
    }

    @CircuitBreaker(name = "helloWorld", fallbackMethod = "getHelloWorld_fallback")
    public Mono<String> getHelloWorld() {
        return this.webClient.get().uri(HELLOWORLD_PATH)
                .retrieve().bodyToMono(String.class);
    }

    public Mono<String> getHelloWorld_fallback(Throwable ex) {
        return Mono.just(HELLOWORLD_FALLBACK_RESULT);
    }

}

Enfin, il faut paramétrer le circuit breaker via l’application.yml:

resilience4j:
  circuitbreaker:
    instances:
      helloWorld: #the name that was defined in the annotation
        slidingWindowSize: 10 #Configures the size (COUNT_BASED) of the sliding window which is used to record the outcome of calls when the CircuitBreaker is closed.
        failureRateThreshold: 50 #Configures the failure rate threshold in percentage.
        permittedNumberOfCallsInHalfOpenState: 5 #Configures the number of permitted calls when the CircuitBreaker is half open.
        waitDurationInOpenState: 5000 #The time that the CircuitBreaker should wait before transitioning from open to half-open.
        registerHealthIndicator: true #Activate observability

Ce sont les paramètres principaux utiles à notre exemple: vous pouvez consulter l’intégralité des paramètres disponibles dans la documentation de Resilience4J. Outre la panne du service, il est par exemple possible de considérer les lenteurs comme déclencheur du circuit breaker (cf slowCallDurationThreshold & slowCallRateThreshold).

Observabilité #

L'efficacité d'un circuit breaker ne se mesure pas seulement par sa capacité à prévenir les défaillances, mais aussi par sa transparence opérationnelle. L'observabilité, qui englobe la surveillance, le traçage et la journalisation, est essentielle pour comprendre le comportement d'un circuit breaker en action.

Resilience4J s’intègre totalement avec actuator. Pour activer le endpoint permettant de récupérer les informations, il faut donc, si ce n’est pas déjà le cas sur l’application, ajouter la dépendance vers actuator:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Pour récupérer les informations relatives au circuit breaker, il suffit ensuite d’utiliser le endpoint “/actuator/circuitbreakers” qui retournera un résultat similaire à celui ci-dessous. Ici, le circuit breaker est à l’état ouvert suite à 10 appels erronés sur 10 soit 100% d’échec:

{
    "circuitBreakers": {
        "helloWorld": {
            "bufferedCalls": 10,
            "failedCalls": 10,
            "failureRate": "100.0%",
            "failureRateThreshold": "50.0%",
            "notPermittedCalls": 6,
            "slowCallRate": "0.0%",
            "slowCallRateThreshold": "100.0%",
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "state": "OPEN"
        }
    }
}

Grâce à ces données, vous pourrez, par exemple, obtenir un tableau de bord tel que celui ci-dessous en utilisant Grafana & Prometheus:

Tableau de bord Grafana Circuit Breaker

Bien que ça ne soit pas l'objet de l'article, le code source contient les éléments nécessaires à la consultation d'un Grafana avec ce type de tableau de bord. Je vous invite à y jeter un oeil si cela vous intéresse.

Les autres fonctionnalités proposées par Resilience4J #

Outre le circuit breaker, Resilience4J propose également les fonctionnalités suivantes:

Conclusion #

Comme vous pouvez le constater, il est relativement simple d’implémenter une fonctionnalité de circuit breaker avec une librairie telle que Resilience4J. Ceci se fait de façon claire grâce aux annotations et aux conventions de nommage proposées par la librairie.

Resilience4J est une solution adaptée dans le cadre des applications Spring Boot mais chaque framework aura probablement sa propre librairie permettant l’intégration de ce type de solutions.

Il existe également des solutions davantage orientées infrastructure telles que Istio (couplé à un proxy Envoy) mais j’estime qu’elle donne moins de flexibilité en terme de fallbacks. Toutefois, elles présentent l’avantage d’être moins intrusives dans le code de l’application.

Nous avons également eu un premier aperçu des capacités de WireMock et de son utilité dans le cadre de développements utilisant des API externes.

Les plus attentifs d’entre vous se demandent peut-être pourquoi j’ai codé la partie serveur “Hello World!” puisqu’elle ne nous a pas servi jusqu'à présent. Et bien, c’est surtout pour que vous puissiez tester la fonctionnalité par vous-même en démarrant/stoppant ce serveur lors de vos essais 😉 Et pour ce faire: retrouvez l’intégralité du code source dans ce repository!