Intermédiaire

Hystrix : une API pour la résilience d’applications

Dans une application orientée services (microservices pour les adeptes !), les différents modules applicatifs font appels à des services plus souvent externes (REST, SOAP…), distants ou pas, comme le schématise la figure ci-dessous.

hystrix

Dans un monde parfait, les services externes répondent dans les temps et, par conséquent, les modules appelants fonctionnent normalement. Mais que se passe-t-il lorsqu’un des services externes (Dependency 1) répond lentement ou, dans le pire des cas, est tombé ?

Sans un mécanisme de protection, le Module A continue à solliciter le service externe Dependency 1 pendant toute la durée de son indisponibilité, ce qui implique une utilisation inutile de ressource (réseau, mémoire, thread…) avec un risque important de ralentir le module A et, par effet domino, toute l’application. De plus, appeler un service qui ne répond pas ne fera que l’engorger et donc empirer sa santé.

Dans le cas d’un service REST, par exemple, un premier niveau de protection à ce type de problème est de définir un time out client, qui permet au module appelant de ne pas attendre une réponse au-delà d’un certain délai.

Ce mécanisme permet, certes, de time-boxer l’attente de réponse, mais n’épargne pas l’application d’une consommation inutile de la bande passante, surtout si la durée d’indisponibilité du service distant est longue. En plus, les time out sont souvent configurés de manière pessimiste avec un délai important. De ce fait, si chaque appel attend le time out pour finalement répondre en erreur, cela ne fera que ralentir l’ensemble de l’application.

La question qui mérite d’être posée est : comment rendre mon application résiliente et tolérante aux pannes d’une dépendance ? Comment mon application doit-t-elle se protéger des problèmes sur lesquels elle n’a pas le contrôle ?

C’est dans ce contexte que je présente le circuit breaker Hystrix que j’ai eu l’occasion de mettre en place chez un client.

Dans cet article, je ne ferai pas de la littérature sur Hystrix, car je ne pourrai pas faire mieux que notre ami Google. Je préfère plutôt montrer un cas d’utilisation pratique pour bien comprendre son fonctionnement.

Circuit breaker : qu’est-ce que c’est ?

Un circuit breaker est un dispositif qui permet d’isoler momentanément une application des problèmes de ses dépendances externes. Son rôle est de couper (court-circuiter) les appels vers un service distant quand celui-ci est indisponible ou répond lentement. On peut faire le parallèle avec le monde électrique où un circuit breaker (le disjoncteur) joue le même rôle pour couper le circuit électrique en cas de problème.

Un circuit breaker peut donc être vu comme un proxy entre l’appelant et l’appelé. Il calcule, pendant une durée, le ratio d’appels en succès et en échec. Il utilise ces statistiques pour décider d’envoyer ou non les requêtes clientes vers le service appelé.
Quand le pourcentage d’échec a atteint un seuil, le circuit breaker suspend momentanément les appels vers ce service.

Et Hystrix ?

Hystrix est une API open source développée par Netflix qui implémente le pattern circuit breaker. Le but d’Hystrix est de rendre une application résiliente aux pannes de ses dépendances externes en arrêtant momentanément de les invoquer le temps de leur indisponibilité. On dit alors que Hystrix ouvre le circuit.

Comme Hystrix n’est pas averti de la reprise d’une dépendance tombée, il tente, à intervalles réguliers, d’appeler cette dépendance. Dès que cette dernière est rétablie, il ferme alors son circuit et tous les appels qu’il reçoit sont transmis à cette dépendance.

Principe de fonctionnement

Hystrix constitue un point d’accès unique à une dépendance externe. Il englobe (wrappe) les appels clients dans un objet suivant le pattern Command. Concrètement, chaque appel distant est wrappé dans un objet HystrixCommand.

Hystrix fonctionne avec deux modes, thread et sémaphore :

Thread : Hystrix maintient son propre pool de threads. Un appel distant est exécuté par un thread Hystrix. Une fois que tous les threads sont pris, les nouveaux appels arrivants sont rejetés.

Sémaphore : l’appel distant est exécuté par le même thread du client appelant. Une fois que le nombre de sémaphores défini est atteint, tous les appels client sont rejetés. Ce mode est préconisé pour les appels courts.

Le mode thread est le mode par défaut d’Hystrix. Il tire son avantage par son isolation du pool de threads de l’appelant.

En plus du circuit breaker, Hystrix offre aussi un mécanisme de Fallback. C’est une sorte de mode dégradé qui fournit au client appelant une réponse par défaut en cas de problème survenu lors de l’invocation d’une dépendance externe.

Exemple de classe implémentant HystrixCommand en mode thread :

class MyCommand extends HystrixCommand {

        public MyCommand() {
            super(
                    Setter.withGroupKey("my_command_group")
                            .andCommandKey("my_command_key")
                            .andThreadPoolKey("my_command_thread_pool")
                            .andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter()
                                    .withCoreSize(10))
                            .andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
                                   .withExecutionTimeoutInMilliseconds(3000)
                                    .withCircuitBreakerRequestVolumeThreshold(5)
                                    .withCircuitBreakerErrorThresholdPercentage(50)
                                    .withMetricsRollingStatisticalWindowInMilliseconds(10000)
                                    .withCircuitBreakerSleepWindowInMilliseconds(10000)));
        }
    }
  • groupKey : permet de regrouper des Command ayant la même configuration.
  • threadPoolKey : définit un pool de thread dédié à un groupe de Command.
    • coreSize : définit la taille du pool de threads Hystrix.
  • commandPropertiesDefaults
    • executionTimeoutInMilliseconds : le délai au-delà duquel Hystrix n’attend plus la réponse de la ressource distante.
    • circuitBreakerRequestVolumeThreshold : le seuil à partir duquel Hystrix peut ouvrir le circuit.
    • circuitBreakerErrorThresholdPercentage : le pourcentage d’appels en erreur VS en succès. Une fois atteint et que le seuil circuitBreakerRequestVolumeThreshold est atteint aussi, Hystrix ouvre le circuit.
    • circuitBreakerSleepWindowInMilliseconds : définit la durée d’ouverture du circuit durant lequel Hystrix n’émet pas d’appel vers la ressource externe.
    • metricsRollingStatisticalWindowInMilliseconds : la fenêtre de temps pendant laquelle Hystrix calcule le pourcentage d’appel en échec (durée d’observation) pour ouvrir ou pas le circuit. les statistiques sont remises à zéro à chaque début de cette fenêtre.

Hystrix comme machine à état

Hystrix implémente le pattern circuit breaker par une machine à état comme illustré dans la figure ci-dessous :

circuit-breaker-states

Closed : pendant cet état, tous les appels sont transmis vers les dépendances externes et des compteurs sont incrémentés à chaque appel en succès et en échec. Durant une durée déterminée (metricsRollingStatisticalWindowInMilliseconds), Hystrix calcule après chaque appel le pourcentage d’appel en échec vs en succès.

Open : quand le pourcentage d’échec calculé précédemment a atteint un seuil déterminé (circuitBreakerErrorThresholdPercentage), Hystrix passe le circuit à l’état Open pendant une durée déterminée (circuitBreakerSleepWindowInMilliseconds) et aucun appel n’est alors transmis à l’extérieur. Si le nombre d’appels n’a pas encore atteint le seuil circuitBreakerRequestVolumeThreshold, le circuit ne s’ouvre pas même si le pourcentage d’échec a atteint le seuil défini.

Half-open : à la réception du premier appel entrant après l’expiration du délai de l’état Open, Hystrix tente de passer l’appel vers l’extérieur :

  • Si l’appel échoue (timeout…), le circuit passe de nouveau à l’état Open.
  • Si par contre l’appel a réussi, le circuit passe à l’état closed et les compteurs sont remis à zéro (circuitBreakerRequestVolumeThreshold et metricsRollingStatisticalWindowInMilliseconds)

Implémentation

Pour illustrer le fonctionnement d’Hystrix, je pars du cas d’utilisation suivant et j’utiliserai le mode thread avec appel synchrone :

Deux ressources Rest distantes fournissent des itinéraires pour aller d’un point A à un point B :
* PublicTransportRemoteResource qui fournit des itinéraires en métro, bus…
* CarRemoteResource qui fournit des itinéraires voiture

Mon application microservices consiste en deux services :
* PublicTransportFinderService qui appelle la ressource PublicTransportRemoteResource
* CarFinderService qui appelle la ressource CarRemoteResource.

Mon application expose une ressource REST ItineraryFinderRessource.

Les ressources distantes retournent une réponse de type RemoteItineraryResponse qui contient une liste d’itinéraires RemoteItineraire.

ItineraryFinderRessource retourne une réponse de type ItineraryResponse qui contient :

  • une liste d’itinéraires Itinerary
  • un flag partial_response qui indique si la réponse construite à partir des microservices est partielle. Un microservice retourne une réponse partielle (réponse mockée) s’il n’a pas réussi à obtenir une réponse de sa dépendance externe.

Les trois ressources REST sont démarrées par spring boot via les classes PtBootstrap, CarBootstrap et MainBootstrap.

  • MainBootstrap
package fr.soat.hystrix;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MainBootstrap {
    public static void main(String[] args) {
        SpringApplication.run(MainBootstrap.class, args);
    }
}

Le code source des exemples ci-dessous est disponible sur github

Je vais utiliser wiremock pour simuler des time out lors de l’appel de la ressource PublicTransportRemoteResource, puis vérifier que mon application continue à répondre correctement avec le résultat de l’autre ressource CarRemoteResource.

Pour des raisons de simplicité de code, je développe mes trois ressources REST (jersey) dans le même projet. Ces ressources sont démarrées sur des ports différents par une simple exécution de la méthode main() des classes PtBootstrap, CarBootstrap et MainBootstrap.

Dépendances maven

<dependency>
    <groupId>com.netflix.hystrix</groupId>
    <artifactId>hystrix-core</artifactId>
    <version>1.5.4</version>
</dependency>
<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock</artifactId>
    <version>1.18</version>
</dependency>

Dans mes tests unitaires, wiremock me servira de serveur proxy pour mocker les appels vers les ressources externes. Il me permettra d’avoir le contrôle sur le temps de réponse et le configurer pour être égal au timeout Hystrix.

Ainsi, je pourrai alors simuler un time out sur une ressource distante.

  • ItineraryReponse
package fr.soat.hystrix.model;

import java.util.*;

public class ItineraryResponse {

    private final List<Itinerary> itineraries;

    private boolean partialResponse; // indique si la réponse est partielle

    public ItineraryResponse() {
        itineraries = new ArrayList<>();
    }

    public ItineraryResponse(List<Itinerary> itineraries) {
        this.itineraries = itineraries;
    }

    public ItineraryResponse(boolean isPartial) {
        this();
        this.partialResponse = isPartial;
    }

    public ItineraryResponse(List<Itinerary> itineraries, boolean partialResponse) {
        this(itineraries);
        this.partialResponse = partialResponse;
    }

    public List<Itinerary> getItineraries() {
        return itineraries;
    }

    public boolean isPartialResponse() {
        return partialResponse;
    }
}
  • RemoteItineraryReponse
package fr.soat.hystrix.model;

import java.util.*;

public class RemoteItineraryResponse {

    private final List<RemoteItinerary> itineraries;

    public RemoteItineraryResponse() {
        itineraries = new ArrayList<>();
    }

    public RemoteItineraryResponse(List<RemoteItinerary> itineraries) {
        this.itineraries = itineraries;
    }

    public List<RemoteItinerary> getItineraries() {
        return itineraries;
    }
}
  • PublicTransportRemoteResource
package fr.soat.hystrix.resource;

import fr.soat.hystrix.model.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import java.util.List;

@Component
@Path("itineraries/publicTransportRemote")
public class PublicTransportRemoteResource {

    @Autowired
    private PublicTransportService publicTransportService;

    @GET
    @Path("find")
    @Produces(MediaType.APPLICATION_JSON)
    public ItineraryResponse getItineraries(@QueryParam("from") String from, @QueryParam("to") String to) {
        List<Itinerary> itineraries = publicTransportService.getItineraries(from, to);
        ItineraryResponse itineraryResponse = new ItineraryResponse(itineraries);
        return itineraryResponse;
    }
}

Cette classe expose un service REST et appelle le service publicTransportService qui construit un itinéraire en bus et un autre en métro (mockés).

  • CarRemoteResource
package fr.soat.hystrix.resource;

import fr.soat.hystrix.model.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import java.util.List;

@Component
@Path("itineraries/carRemote")
public class CarRemoteResource {

    @Autowired
    private CarService carService;

    @GET
    @Path("find")
    @Produces(MediaType.APPLICATION_JSON)
    public ItineraryResponse getItineraries(@QueryParam("from") String from, @QueryParam("to") String to) {
        List<Itinerary> itineraries = carService.getItineraries(from, to);
        ItineraryResponse itineraryResponse = new ItineraryResponse(itineraries);
        return itineraryResponse;
    }
}

Cette classe expose un service REST et appelle le service carService qui construit un itinéraire voiture mocké.

  • ItineraryFinderRessource
package fr.soat.hystrix.resource;

import com.google.common.collect.Lists;
import fr.soat.hystrix.microservice.*;
import fr.soat.hystrix.model.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.ws.rs.*;
import javax.ws.rs.core.*;
import java.util.List;

@Component
@Path("/itineraries")
public class ItineraryFinderResource {

    @Autowired
    CarFinderService carFinderService;

    @Autowired
    PublicTransportFinderService publicTransportFinderService;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("find/{from}/{to}")
    public Response findItineraries(@PathParam("from") String from, @PathParam("to") String to){
        List<Itinerary> itineraries = Lists.newArrayList();
        List<Itinerary> carItineraries = carFinderService.search(from, to).getItineraries();

        List<Itinerary> pTransportItineraries = publicTransportFinderService.search(from, to).getItineraries();

        itineraries.addAll(carItineraries);
        itineraries.addAll(pTransportItineraries);
        ItineraryResponse itineraryResponse = new ItineraryResponse(itineraries);
        Response response = Response.ok().entity(itineraryResponse).build();
        return response;
    }
}

Cette classe est le point d’entrée de mon application. Elle expose un service REST et appelle les deux microservices publicTransportFinderService et carFinderService. Ces services appellent à leur tour les ressources PublicTransportRemoteResource et CarRemoteResource en REST.

  • publicTransportFinderService
package fr.soat.hystrix.microservice;

import com.google.common.collect.Lists;
import com.netflix.hystrix.*;
import com.netflix.hystrix.exception.HystrixRuntimeException;
import fr.soat.hystrix.config.ClientJersey;
import fr.soat.hystrix.model.*;
import org.glassfish.jersey.message.internal.OutboundJaxrsResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.ws.rs.client.*;
import javax.ws.rs.core.*;
import java.util.*;
import static fr.soat.hystrix.model.RemoteCallException.ExceptionType.*;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;

@Component
public class PublicTransportFinderService {

    private HystrixCommandGroupKey PT_HYSTRIX_COMMAND_GROUP_KEY = HystrixCommandGroupKey.Factory.asKey("PublicTransport");
    private HystrixCommandKey PT_HYSTRIX_COMMAND_KEY = HystrixCommandKey.Factory.asKey("PublicTransport");
    private HystrixThreadPoolKey PT_HYSTRIX_POOL_KEY = HystrixThreadPoolKey.Factory.asKey("PublicTransport");
    private final String URI = "http://localhost:8091";

    @Autowired
    ClientJersey clientJersey;

    public ItineraryResponse search(String from, String to) {

        Response response = null;
        final ItineraryResponse itineraryResponse;
        PTHystrixCommand hystrixCommand = null;
        try {
            UriBuilder uriBuilder = getUriBuilder(from, to);
            final WebTarget target = clientJersey.getClient().target(uriBuilder);
            final Invocation.Builder request = target.request(APPLICATION_JSON_TYPE);
            hystrixCommand = new PTHystrixCommand(request);
            response = hystrixCommand.execute();
        } catch (HystrixRuntimeException e) {
            if (hystrixCommand != null) {
                hystrixCommand.closeConnection();
            }
            switch (e.getFailureType()) {
                case TIMEOUT:
                    throw new RemoteCallException(TIMEOUT, hystrixCommand.getExecutionTimeInMilliseconds(), e);
                case SHORTCIRCUIT:
                    throw new RemoteCallException(HYSTRIX_OPEN_CIRCUIT, hystrixCommand.getExecutionTimeInMilliseconds(), e);
                case REJECTED_THREAD_EXECUTION:
                    throw new RemoteCallException(HYSTRIX_REJECTED_THREAD_EXECUTION,   hystrixCommand.getExecutionTimeInMilliseconds(), e);
                default:
                    throw new RemoteCallException(OTHER, hystrixCommand.getExecutionTimeInMilliseconds(), e);
            }
        }

        if(response.getEntity() instanceof ItineraryResponse){
            itineraryResponse = (ItineraryResponse)response.getEntity();
        } else {
            itineraryResponse = adapteRemoteResponse(response.readEntity(RemoteItineraryResponse.class));
        }
        return itineraryResponse;
    }

    private UriBuilder getUriBuilder(String from, String to) {
        return UriBuilder.fromUri(URI)
                .path("itineraries/publicTransportRemote/find")
                .queryParam("from", from)
                .queryParam("to", to);
    }

    private class PTHystrixCommand extends HystrixCommand<Response> {

        private final Invocation.Builder request;

        private Response response = null;

        public PTHystrixCommand(Invocation.Builder request) {
            super(
                    Setter.withGroupKey(PT_HYSTRIX_COMMAND_GROUP_KEY)
                            .andCommandKey(PT_HYSTRIX_COMMAND_KEY)
                            .andThreadPoolKey(PT_HYSTRIX_POOL_KEY)
                            .andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter()
                                    .withCoreSize(10))
                            .andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
                                    .withExecutionTimeoutInMilliseconds(3000)
                                    .withCircuitBreakerRequestVolumeThreshold(2)
                                    .withCircuitBreakerErrorThresholdPercentage(50)
                                    .withMetricsRollingStatisticalWindowInMilliseconds(10000)
                                    .withCircuitBreakerSleepWindowInMilliseconds(10000)));

            this.request = request;
        }

        @Override
        protected Response run() throws Exception {
            response = request.get();
            return response;
        }

        @Override
        protected Response getFallback() {
            System.out.println(this.circuitBreaker.isOpen());
            return Response.ok(new ItineraryResponse(true)).build();
        }

        public void closeConnection() {
            if (response != null) {
                response.close();
            }
        }
    }
}

Cette classe est le microservice responsable d’effectuer les appels REST vers la dépendance externe PublicTransportRemoteResource.

Pour rendre mon microservice résilient aux éventuels problèmes liés à cette dépendance, j’y ai introduit Hystrix.

Pour cela, j’ai englobé (wrappé) l’objet Jersey request dans un objet de type HystrixCommand que j’ai appelé PTHystrixCommand

hystrixCommand = new PTHystrixCommand(request);
response = hystrixCommand.execute();
  • PTHystrixCommand
private class PTHystrixCommand extends HystrixCommand {

    private final Invocation.Builder request;

    private Response response = null;

    public PTHystrixCommand(Invocation.Builder request) {
        super(
                Setter.withGroupKey(PT_HYSTRIX_COMMAND_GROUP_KEY)
                        .andCommandKey(PT_HYSTRIX_COMMAND_KEY)
                        .andThreadPoolKey(PT_HYSTRIX_POOL_KEY)
                        .andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter()
                                .withCoreSize(10))
                        .andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
                                .withExecutionTimeoutInMilliseconds(2000)
                                .withCircuitBreakerRequestVolumeThreshold(5)
                                .withCircuitBreakerErrorThresholdPercentage(50)
                                .withMetricsRollingStatisticalWindowInMilliseconds(10000)
                                .withCircuitBreakerSleepWindowInMilliseconds(5000)));

        this.request = request;
    }

    @Override
    protected Response run() throws Exception {
        response = request.get();
        return response;
    }

    @Override
    protected Response getFallback() {
        boolean partialResponse = true;
                    return Response.ok(new ItineraryResponse(partialResponse)).build();
    }

    public void closeConnection() {
        if (response != null) {
            response.close();
        }
    }
}

Cette classe étend HystrixCommand et redéfinit la méthode run() invoquée sur l’appel hystrixCommde.execute()

Mon microservice délègue donc l’appel REST request.get() vers la ressource externe à la méthode run() d’Hystrix.

La méthode getFallback() permet à Hystrix de retourner une réponse par défaut en cas d’indisponibilité de la ressource externe (time out ou circuit ouvert). Dans mon cas, elle retourne une réponse sans itinéraire avec une indication que la réponse est partielle (partialReponse = true).

En cas de problème d’appel distant, Hystrix lève une exception de type HystrixRuntimeException.

Les raisons de la levée de cette exception sont principalement :

  • TIMEOUT : dépendance externe UP mais répond au-delà du délai timeout d’Hystrix.
  • SHORTCIRCUIT : l’appel distant n’est pas effectué car le circuit breaker est ouvert.
  • REJECTED_THREAD_EXECUTION : les requêtes entrantes sont rejetées car il ne reste aucun thread dans le pool (ça se produit quand tous les threads Hystrix attendent une réponse d’une dépendance lente.

Hystrix ne lève pas d’exception si la méthode *getFallback()* est redéfinie dans la Command hsytrix

L’appel de la méthode execute() déclenche un appel synchrone. Pour les appels asynchrones, il faut utiliser la méthode queue() qui retourne une Future.

Bien configurer la taille du pool de thread

Pour une meilleure configuration de la taille du pool de threads, voici la formule à appliquer :

Taille = (nombre de requête/seconde en période de pic * le temps de réponse d’une requête au 99ème penrcentile) + une marge de thread.*

Exemple:

Supposant qu’un service appelle une ressource externe 50 req/s en période de pic et que dans le pire des cas, la ressource externe répond en 1 seconde.

La taille = (50 * 1) + marge de 5 threads = 55

Dans mes exemples ci-dessus, j’ai configuré PTHystrixCommand comme suit :

  • time out est 3 secondes.
  • pourcentage d’appels en échec vs appels en succès est de 50%.
  • seuil à partir duquel le circuit est susceptible d’être ouvert est de 5 requêtes. Si ce seuil n’est pas encore atteint, le circuit ne s’ouvre pas même si le pourcentage d’échec a atteint les 50%.
  • durée d’ouverture du circuit est de 5 secondes. Hystrix ne fait aucun appel distant durant ce temps.

Le pourcentage d’échec d’appels est remis à zéro toutes les 10 secondes.

Tester l’application

Pour illustrer le fonctionnement d’Hystrix je vais tester 3 cas :

  • Le cas nominal où tout fonctionne normalement.
  • Le fallback dans le cas où une des ressources externes est tombée.
  • Le circuit breaker où une des ressources externes répond au-delà du time out Hystrix.

Cas nominal :

Je démarre mes trois ressources REST (méthodes main() des Bootstrap)

En lançant la requête http://localhost:8080/itineraries/find/A/B, j’obtiens la réponse suivante :

{
    itineraries: [
        {
        departure_point: "Adresse A",
        arrival_point: "Adresse B",
        transport_mode: "Car",
        distance: 5000,
        departure_time: "2016-11-05T02:11:45.524+01:00",
        arrival_time: "2016-11-05T02:56:45.524+01:00"
        },
        {
        departure_point: "Arrêt A",
        arrival_point: "Arrêt B",
        transport_mode: "Bus",
        line: "170",
        departure_time: "2016-11-05T02:11:46.191+01:00",
        arrival_time: "2016-11-05T02:56:46.195+01:00"
        },
        {
        departure_point: "Arrêt A",
        arrival_point: "Arrêt B",
        transport_mode: "Metro",
        line: "14",
        departure_time: "2016-11-05T02:41:46.195+01:00",
        arrival_time: "2016-11-05T02:56:46.196+01:00"
        }
    ],
    partial_response: false
}

Le premier itinéraire est remonté par la ressource CarRemoteResource
Les 2 autres itinéraires sont remontés par la ressource PublicTransportRemoteResource

Le champ partial_response est valorisé à false, cela indique que les appels REST se sont bien passés et que ma réponse est complète.

Le fallback

J’arrête maintenant la ressource PublicTransportRemoteResource (elle devient inaccessible depuis mon microservice *PublicTransportFinderService)

J’obtiens alors la réponse suivante :

{
    itineraries: [
        {
        departure_point: "Adresse A",
        arrival_point: "Adresse B",
        transport_mode: "Car",
        distance: 5000,
        departure_time: "2016-11-05T02:11:45.524+01:00",
        arrival_time: "2016-11-05T02:56:45.524+01:00"
        }
    ],
    partial_response : true
}

Cette réponse est partielle. Elle ne contient que l’itinéraire envoyé par la ressource disponible CarRemoteResource. Le microsevice PublicTransportFinderService n’a pas réussi à établir la connexion avec sa dépendance externe, il retourne alors une réponse vide avec le falg partialResponse à true via l’appel de la méthode fallback() redéfinie dans PTHystrixCommand..

Le circuit breaker

Je vais maintenant simuler un time out lorsque PublicTransportService appelle sa dépendance. Pour cela, je vais mocker à l’aide de wiremock l’appel REST pour augmenter la durée de réponse au-delà du time out Hystrix (2000 ms).

Pour des raisons de simplicité de test, je désactive le fallback pour lever les exceptions Hystrix et faire des assertions sur leur type.
Je modifie donc ma classe PTHystrixCommand comme suit :

.withExecutionTimeoutInMilliseconds(3000)
.withCircuitBreakerRequestVolumeThreshold(2)
.withCircuitBreakerErrorThresholdPercentage(50)
.withFallbackEnabled(false)
.withMetricsRollingStatisticalWindowInMilliseconds(10000)
.withCircuitBreakerSleepWindowInMilliseconds(10000)));
  • Test 1:
@Test
    public void hystrix_dependencyTimeout_hystrixOpenCircuit() {

        // Given
        exception.expect(RemoteCallException.class);
        exception.expect(new RemoteExceptionTypeMatcher(HYSTRIX_OPEN_CIRCUIT));
        exception.expect(new RemoteExceptionDurationMatcher(-1));

        final int responseDelay = DEPENDENCY_TIMEOUT + 100; // marge de 100 ms

        WireMock.stubFor(WireMock.any(WireMock.urlMatching(".*")).willReturn(
                WireMock.aResponse().withFixedDelay(responseDelay))
        );

        // When
        for (int i = 0; i < OPEN_CIRCUIT_THRESHOLD; i++) { // On fait autant de tentatives qu'il faut pour atteindre le seuil d'ouverture du circuit
            try {
                publicTransportFinderService.search(Mockito.anyString(), Mockito.anyString());
            } catch (RemoteCallException e) {
                System.out.println(String.format("appel %d sur %d >> %s", i + 1, OPEN_CIRCUIT_THRESHOLD, ExceptionType.TIMEOUT.name()));
                assertThat(e.getExceptionType() == ExceptionType.TIMEOUT);
                assertThat(e.getCallDuration()).isCloseTo(DEPENDENCY_TIMEOUT, Offset.offset(100));
            }
        }
        // faire un appel après que le seuil OPEN_CIRCUIT_THRESHOLD soit atteint
        Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); // on attend 1 seconde pour laisser le temps à Hystrix d'ouvrir le circuit
        System.out.println(String.format("appel %d >> %s", OPEN_CIRCUIT_THRESHOLD + 1, ExceptionType.HYSTRIX_OPEN_CIRCUIT.name()));
        publicTransportFinderService.search(Mockito.anyString(), Mockito.anyString());
        // Then --> exception levée
    }

Dans ce test, j’ai démarré un serveur wiremock sur le même port que celui de la ressource distante PublicTransportFinderRemote (8091)

Tous les appels vers cette ressource sont mockés (WireMock.urlMatching(“.itineraries/publicTransportRemote/find.“)) et répondent en un délai supérieur au time out Hystrix fixé à 2000 ms (WireMock.aResponse().withFixedDelay(responseDelay)). Avec ce délai, tous les appels hystrix doivent tomber en time out avant l’ouverture du circuit.

La classe PTHystrixCommand est configurée comme suit :

  • Le seuil des requêtes Hystrix pour une éventuelle ouverture de circuit est de 5.
  • Le pourcentage d’échec pour l’ouverture du circuit est de 50%.

Dans un premier temps, le test lance 5 appels de suite et vérifie que l’exception remontée est causée par un TIMEOUT et que la durée des appels est égale au time out Hystrix.

Après que le seuil des 5 appels soit atteint et qu’au moins 50% des appels échouent (100% dans mon cas), Hystrix ouvre le circuit.

L’appel suivant l’ouverture du circuit lève une exception de type HYSTRIX_OPEN_CIRCUIT. La durée de cet appel est de -1 car Hystrix n’a pas fait d’appel REST : c’est le principe du Fail fast.

Voici la trace d’exécution du test :

appel 1 sur 5 >> TIMEOUT >> durée 2018 ms
appel 2 sur 5 >> TIMEOUT >> durée 2002 ms
appel 3 sur 5 >> TIMEOUT >> durée 2003 ms
appel 4 sur 5 >> TIMEOUT >> durée 2001 ms
appel 5 sur 5 >> TIMEOUT >> durée 2002 ms
appel 6 >> HYSTRIX_OPEN_CIRCUIT >> durée -1
  • Test 2:
@Test
    public void hystrix_circuitOpen_CircuitIsClosedWhenDurationIsReached() {
        // Given
        final int responseDelay = DEPENDENCY_TIMEOUT + 100;

        WireMock.stubFor(WireMock.any(WireMock.urlMatching(".*itineraries/publicTransportRemote/find.*")).willReturn(
                WireMock.aResponse().withStatus(
                        Response.Status.OK.getStatusCode()
                ).withFixedDelay(responseDelay)
                )
        );

        // When
        for (int i = 0; i < OPEN_CIRCUIT_THRESHOLD; i++) { //On fait autant de tentatives qu'il faut pour ouvrir le circuit
            try {
                publicTransportFinderService.search(Mockito.anyString(), Mockito.anyString());
            } catch (RemoteCallException e) {
                System.out.println(String.format("appel %d sur %d >> %s", i + 1, OPEN_CIRCUIT_THRESHOLD, ExceptionType.TIMEOUT.name()));
                assertThat(e.getExceptionType() == ExceptionType.TIMEOUT);
            }
        }

        // faire un appel après que le seuil OPEN_CIRCUIT_THRESHOLD soit atteint
        Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); // on attend 1 seconde pour laisser le temps à Hystrix d'ouvrir le circuit
        try {
            publicTransportFinderService.search(Mockito.anyString(), Mockito.anyString());
        } catch (RemoteCallException e) {
            System.out.println(String.format("appel %d >> %s", OPEN_CIRCUIT_THRESHOLD + 1, ExceptionType.HYSTRIX_OPEN_CIRCUIT.name()));
            assertThat(e.getExceptionType() == ExceptionType.HYSTRIX_OPEN_CIRCUIT);
        }
        //On attend que le circuit se ferme
        System.out.println(String.format("attendre %d ms, la durée d'ouverture du circuit ...", OPEN_CIRCUIT_DURATION));
        Uninterruptibles.sleepUninterruptibly(OPEN_CIRCUIT_DURATION, TimeUnit.MILLISECONDS);

        // On fait une nouvelle tentative qui doit être en timeout et le circuit s'ouvre de nouveau
        try {
            publicTransportFinderService.search(Mockito.anyString(), Mockito.anyString());
        } catch (RemoteCallException e) {
            System.out.println("appel après expiration du délai OPEN_CIRCUIT_DURATION >> TIMEOUT");
            assertThat(e.getExceptionType() == ExceptionType.TIMEOUT);
        }

        // On fait un autre appel et maintenant le circuit est bien ouvert de nouveau
        try {
            publicTransportFinderService.search(Mockito.anyString(), Mockito.anyString());
        } catch (RemoteCallException e) {
            System.out.println("appel après réouverture du circuit >> HYSTRIX_OPEN_CIRCUIT");
            assertThat(e.getExceptionType() == ExceptionType.HYSTRIX_OPEN_CIRCUIT);
        }
    }

Ce test vérifie la bonne fermeture du circuit après expiration du délai de son ouverture (5000 ms).

Le circuit est ouvert après 6 appels. Le test observe une attente de OPEN_CIRCUIT_DURATION ms le temps que le circuit passe à l’état Half-open.

L’appel juste après l’expiration du délai OPEN_CIRCUIT_DURATION tombe en TIMEOUT et le circuit s’ouvre de nouveau.

Voici la trace d’exécution du test :

appel 1 sur 5 >> TIMEOUT >> durée 2015 ms
appel 2 sur 5 >> TIMEOUT >> durée 2003 ms
appel 3 sur 5 >> TIMEOUT >> durée 2004 ms
appel 4 sur 5 >> TIMEOUT >> durée 2003 ms
appel 5 sur 5 >> TIMEOUT >> durée 2001 ms
appel 6 >> HYSTRIX_OPEN_CIRCUIT >> durée -1
attendre 2100 ms, la durée d'ouverture du circuit ...
appel après expiration du délai OPEN_CIRCUIT_DURATION >> TIMEOUT >> durée 2000 ms
appel après réouverture du circuit >> HYSTRIX_OPEN_CIRCUIT >> durée -1

Faire travailler Hystrix à grandeur réelle

Pour mieux comprendre la machine à état d’Hystrix, j’ai réalisé un mini test de perf sur mon application avec gatling.
J’ai utilisé wiremock pour proxifier les appels vers la ressource PublicTransportFinderRessource.

Voici le scénario du test :

  • Test avec 1 user/sec pendant 4 mn
  • La durée de réponse de la ressource PublicTransportFinderRessource est fixée à :
    • 500 ms pendant une minute
    • 2100 ms pendant 3 minutes (délai supérieur au timeout Hystrix fixé à 2000 ms)
    • 500 ms jusqu’à la fin du test

Voici le résultat du tir :

Analyse

Dans cette figure, on voit qu’au début du test, à 10:29:30, le temps de réponse est légèrement supérieur à 500 ms, ce qui est normal puisque l’une des ressources est configurée pour répondre à 500 ms.

Entre 10:29:30 et 10:33:00, j’ai augmenté le temps de réponse d’une ressource à 2100 ms (supérieur au time out Hystrix).

En observe bien que Hystrix a ouvert le circuit et que le temps de réponse est maintenant proche du zéro, car Hystrix ne fait pas d’appels vers la ressource qui répond en time out.

A partir de 10:33:00, le temps de réponse est passé de nouveau à 550 ms environ, car à ce moment-là, j’ai rétabli la ressource en erreur en fixant son délai de réponse à 500 ms.

Un zoom sur la période d’ouverture du circuit donne :

Dans cette figure, on voit bien que pendant toute la durée d’indisponibilité d’une ressource, Hystrix tente des appels toutes les 20 secondes (pic à 200 ms) mais revient à chaque fois à l’état d’ouverture de circuit (temps de réponse proche de zéro) tant que cette ressource n’est pas rétablie.

A partir de 10:33:00, la ressource est rétablie, Hystrix ferme donc son circuit et le temps de réponse de mon application revient à la normale.

Le mot de la fin…

La mise en place d’Hystrix se justifie dès lors qu’une application dépend des ressources externes (distantes ou locales). Avec son circuit breaker, il protège une application des problèmes qui peuvent survenir lors de l’invocation de ses dépendances externes (indisponibilité, lenteur, ..). Il arrête ainsi de les invoquer momentanément et donc leur laisse le temps de se refaire une santé.

L’intégration d’Hystrix dans une application ne demande pas beaucoup d’effort. Une simple classe HystrixCommand pour wrapper les appels suffit.

La résilience est un aspect crucial à ne pas négliger dans une application ayant des dépendances vers l’extérieur.

Mettre en place un circuit breaker type Hystrix ne peut apporter que du positif à l’application :

  • Performance : répondre rapidement en cas d’indisponibilité d’une ressource.
  • Diminuer la charge réseau (pas d’appel externe quand le circuit est ouvert).
  • Désengorger la ressource externe en erreur pour lui donner le temps de se rétablir.

Hystrix offre aussi d’autres fonctionnalités que je n’ai pas abordées dans mon article à savoir :

  • Caching des résultats (méthode getCacheKey() dans HystrixCommand)
  • Exécution des requêtes par batch (HystrixCollapser)
  • Monitoring des métriques

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT

Nombre de vue : 1169

AJOUTER UN COMMENTAIRE