Spring: Resilience4j

Introduction

Dans un environnement de microservices coopérants, les pannes doivent être considérées comme un état normal et le paysage système doit être conçu pour les gérer. Pour cela, il existe des bibliothèques open source de tolérance aux pannes, telles que Resilience4j.

Resilience4j est recommandé depuis la sortie de Spring Cloud Greenwich, car il offre une plus grande gamme de mécanismes de tolérance aux pannes par rapport à Netflix Hystrix. Voici quelques exemples :

  • Le disjoncteur est utilisé pour empêcher une chaîne de réaction de défaillance si un service à distance cesse de répondre.
  • Le limiteur de débit est utilisé pour limiter le nombre de demandes à un service pendant une période de temps spécifiée.
  • Bulkhead est utilisé pour limiter le nombre de demandes simultanées à un service.
  • Les nouvelles tentatives sont utilisées pour gérer les erreurs aléatoires qui peuvent se produire de temps à autre.
  • Le limiteur de temps est utilisé pour éviter d'attendre trop longtemps une réponse d'un service lent ou ne répondant pas.

Le disjoncteur suit la conception classique d'un disjoncteur, qui peut être illustrée par le diagramme d'état suivant :

  • Il démarre en étant fermé, permettant le traitement des demandes.
  • Tant que les demandes sont traitées avec succès, il reste à l'état fermé.
  • Si des échecs commencent à se produire, un compteur commence à compter.
  • Si un seuil de pannes est atteint dans un laps de temps spécifié, le disjoncteur se déclenchera, passant à l'état ouvert et ne permettant pas de traiter d'autres demandes.
  • Au lieu de cela, une requête sera en mode fast fail, ce qui signifie qu'elle retournera immédiatement avec une exception.
  • Après une période de temps configurable, le disjoncteur entrera dans un état semi-ouvert et permettra à une demande de passer, en tant que sonde, pour voir si la panne a été résolue.
  • Si la demande de sonde échoue, le disjoncteur repasse à l'état ouvert.
  • Si la requête de sonde réussit, le disjoncteur revient à l'état initial fermé, permettant le traitement de nouvelles requêtes.

En utilisant Resilience4j et en configurant correctement les mécanismes de tolérance aux pannes, vous pouvez améliorer la stabilité et la résilience de votre système, en permettant de gérer les pannes de manière efficace et prévisible.

Resilence4j

Resilience4j est une bibliothèque open source de tolérance aux pannes qui peut être utilisée pour protéger un service REST, tel que myService, contre les erreurs internes, telles que l'incapacité à atteindre un service dépendant.

Si le service protégé commence à produire des erreurs, le disjoncteur Resilience4j s'ouvre après un certain nombre de tentatives configurables, empêchant ainsi les demandes supplémentaires et renvoyant un message d'erreur, tel que "CircuitBreaker 'myService' is open". Lorsque l'erreur est résolue et qu'une nouvelle tentative est effectuée (après un temps d'attente configurable), le disjoncteur autorise une nouvelle tentative en tant que sonde. Si l'appel réussit, le disjoncteur revient à l'état fermé, c'est-à-dire qu'il fonctionne normalement :

 curl $HOST:$PORT/actuator/health -s | jq .components.circuitBreakers

S'il fonctionne normalement, c'est-à-dire que le circuit est fermé , il répondra par quelque chose comme ce qui suit :

Si quelque chose ne va pas et le circuit est ouvert , il répondra avec quelque chose comme ce qui suit :

Avec l'introduction de Resilience4j, nous avons vu comment le disjoncteur peut être utilisé pour gérer les erreurs pour un client REST. Les mécanismes de disjoncteur, de limiteur de temps et de nouvelle tentative peuvent être utiles dans toute communication synchrone entre deux composants logiciels, tels que des microservices.

Dans cette section, nous allons appliquer ces mécanismes dans un seul endroit, c'est-à-dire les appels du product-compositeservice au productservice. Cette approche est illustrée dans la figure suivante :

Notez que les appels synchrones aux serveurs de découverte et de configuration depuis les autres microservices ne sont pas représentés dans le schéma précédent. Cette omission est faite dans le but de faciliter la lecture du schéma.

Présentation du disjoncteur

Fonctionnalités clés d'un disjoncteur et configuration avec Resilience4j

Les fonctionnalités clés d'un disjoncteur sont les suivantes :

  • Si un disjoncteur détecte trop de défauts, il ouvrira son circuit, c'est-à-dire qu'il n'autorisera pas de nouveaux appels.
  • Lorsque le circuit est ouvert, un disjoncteur exécute une logique de défaillance rapide. Cela signifie qu'il redirige directement l'appel vers une méthode de secours qui peut appliquer diverses logiques métier pour produire une réponse optimale. Cela empêche un microservice de devenir ne répond pas si les services dont il dépend cessent de répondre normalement.
  • Après un certain temps, le disjoncteur sera à moitié ouvert, permettant aux nouveaux appels de voir si le problème à l'origine des pannes a disparu. Si de nouvelles pannes sont détectées, le disjoncteur rouvrira le circuit et reviendra à la logique fail-fast. Sinon, il fermera le circuit et reviendra au fonctionnement normal.

Resilience4j expose des informations sur les disjoncteurs lors de l'exécution de plusieurs manières :

  • L'état actuel d'un disjoncteur peut être surveillé à l'aide du point de terminaison actuator health du microservice, /actuator/health.
  • Le disjoncteur publie également des événements sur un point de terminaison actuator, par exemple, des transitions d'état, /actuator/circuitbreakerevents.
  • Les disjoncteurs sont intégrés au système de métriques de Spring Boot et peuvent l'utiliser pour publier des métriques dans des outils de surveillance tels que Prometheus.

Pour contrôler la logique dans un disjoncteur, Resilience4j peut être configuré à l'aide de la configuration Spring Boot standard des dossiers. Les paramètres de configuration suivants sont utilisés :

  • slidingWindowType : pour déterminer si un disjoncteur doit être ouvert, Resilience4j utilise une fenêtre glissante, comptant les événements les plus récents pour prendre la décision. Les fenêtres glissantes peuvent être basées sur un nombre fixe d'appels ou sur un temps écoulé fixe. Ce paramètre est utilisé pour configurer le type de fenêtre coulissante utilisé. Nous utiliserons une fenêtre glissante basée sur le nombre, en définissant ce paramètre sur COUNT_BASED.
  • slidingWindowSize : le nombre d'appels dans un état fermé qui sont utilisés pour déterminer si le circuit doit être ouvert. Nous mettrons ce paramètre à 5.
  • failureRateThreshold : le seuil, en pourcentage, pour les appels échoués qui entraîneront l'ouverture du circuit. Nous mettrons ce paramètre à 50%.
  • automaticTransitionFromOpenToHalfOpenEnabled : détermine si le disjoncteur passera automatiquement à l'état semi-ouvert une fois la période d'attente terminée. Nous mettrons ce paramètre à true.
  • waitDurationInOpenState : spécifie combien de temps le circuit reste dans un état ouvert, c'est-à-dire avant qu'il ne passe à l'état
 management.health.circuitbreakers.enabled: true

Présentation du limiteur de temps

Pour aider un disjoncteur à gérer des services lents ou qui ne répondent pas, un mécanisme de temporisation peut être utile. Le mécanisme de temporisation de Resilience4j, appelé TimeLimiter, peut être configuré à l'aide des fichiers de configuration Spring Boot standard.

Nous utiliserons le paramètre de configuration suivant :

  • timeoutDuration : spécifie combien de temps une instance TimeLimiter attend qu'un appel se termine avant de lever une exception de délai d'attente. Nous le mettrons à 2 secondes.

Présentation du mécanisme de nouvelle tentative

Le mécanisme de nouvelle tentative est très utile pour les erreurs aléatoires et peu fréquentes, telles que les problèmes réseau temporaires. Ce mécanisme peut simplement réessayer une demande échouée un certain nombre de fois avec un délai configurable entre les tentatives. Toutefois, une restriction très importante sur son utilisation est que les services qu'il réessaye doivent être idempotents, c'est-à-dire qu'appeler le service une ou plusieurs fois avec les mêmes paramètres de requête doit donner le même résultat. Par exemple, la lecture d'informations est idempotente, mais la création d'informations ne l'est généralement pas.

Resilience4j expose les informations de nouvelle tentative de la même manière que pour les disjoncteurs en ce qui concerne les événements et les métriques, mais ne fournit aucune information sur la santé. Les événements de nouvelle tentative sont accessibles sur le point de terminaison actuator, /actuator/retryevents.

Pour contrôler la logique de nouvelle tentative, Resilience4j peut être configuré à l'aide de fichiers de configuration Spring Boot standard. Nous utiliserons les paramètres de configuration suivants :

  • maxAttempts : Le nombre de tentatives avant d'abandonner, y compris le premier appel. Nous définirons ce paramètre sur 3, autorisant un maximum de deux nouvelles tentatives après un premier appel ayant échoué.
  • waitDuration : Le temps d'attente avant la prochaine tentative de relance. Nous allons définir cette valeur sur 1000 ms, ce qui signifie que nous attendrons 1 seconde entre les tentatives.
  • retryExceptions : une liste d'exceptions qui déclencheront une nouvelle tentative. Nous ne déclencherons de nouvelles tentatives que sur les exceptions InternalServerError, c'est-à-dire lorsque les requêtes HTTP répondent avec un code d'état 500.

Example d'implémentation

Les mécanismes de résilience tels que le disjoncteur, le limiteur de temps et la nouvelle tentative sont utiles pour gérer les erreurs dans les communications synchrones entre deux composants logiciels, comme des microservices. Nous allons utiliser ces mécanismes dans les appels du service composite-product au service product.

Les fonctionnalités clés d'un disjoncteur sont :

  • S'il détecte trop de défauts, il ouvrira son circuit et n'autorisera pas de nouveaux appels.
  • Il exécute une logique de défaillance rapide lorsque le circuit est ouvert,
    • Il redirige directement l'appel vers une méthode de secours plutôt que d'attendre une nouvelle erreur.
    • La méthode de secours peut renvoyer des données à partir d'un cache local ou simplement renvoyer un message d'erreur immédiat pour empêcher un microservice de devenir ne répond pas.
  • Après un certain temps, le disjoncteur sera à moitié ouvert, permettant aux nouveaux appels de voir si le problème à l'origine des pannes a disparu.
  • Resilience4j expose des informations sur les disjoncteurs pour surveiller leur état.

Le mécanisme de temporisation appelé TimeLimiter peut être utilisé pour aider un disjoncteur à gérer des services lents ou qui ne répondent pas. Resilience4j expose également un mécanisme de nouvelle tentative qui peut gérer les erreurs aléatoires et peu fréquentes.

Pour tester ces mécanismes, il est nécessaire de pouvoir contrôler quand des erreurs se produisent. Pour cela, nous avons ajouté deux paramètres de requête facultatifs à l'API getProduct du microservice :

  • delay : oblige l'API getProduct à retarder sa réponse, en spécifiant le délai en secondes.
  • faultPercentage : oblige l'API getProduct à lever une exception de manière aléatoire avec la probabilité spécifiée par le paramètre de requête.

Ces paramètres de requête ont été définis dans les interfaces Java du projet.

ProductCompositeService :

 Mono<ProductAggregate> getProduct(
    @PathVariable int productId,
    @RequestParam(value = "delay", required = false, defaultValue =
    "0") int delay,
    @RequestParam(value = "faultPercent", required = false, 
    defaultValue = "0") int faultPercent
);

ProductService

 Mono<Product> getProduct(
     @PathVariable int productId,
     @RequestParam(value = "delay", required = false, defaultValue
     = "0") int delay,
     @RequestParam(value = "faultPercent", required = false, 
     defaultValue = 

Les paramètres de requête ont des valeurs par défaut qui désactivent l'utilisation des mécanismes d'erreur. Ils sont facultatifs et leur utilisation n'est pas obligatoire. Si aucun de ces paramètres n'est utilisé dans une requête, aucun délai ne sera appliqué et aucune erreur ne sera générée.

Modifications du microservice produit-composite

Le microservice product-composite agit comme un intermédiaire en transmettant simplement les paramètres à l'API du produit. Lorsqu'il reçoit une demande d'API, le service transmet les paramètres au composant d'intégration qui appelle l'API du produit.

L'appel à l'API du produit est effectué à partir de la classe ProductCompositeServiceImpl en appelant le composant d'intégration.

 public Mono<ProductAggregate> getProduct(int productId,
  int delay, int faultPercent) {
    return Mono.zip(
        ...
        integration.getProduct(productId, delay, faultPercent),
        ....

L'appel de l'API du produit depuis la classe ProductCompositeIntegration ressemble à ceci :

 public Mono<Product> getProduct(int productId, int delay, 
  int faultPercent) {
  
    URI url = UriComponentsBuilder.fromUriString(
      PRODUCT_SERVICE_URL + "/product/{productId}?delay={delay}" 
      + "&faultPercent={faultPercent}")
      .build(productId, delay, faultPercent);
  return webClient.get().uri(url).retrieve()...

Modifications du microservice

Le microservice product implémente le retard réel et le générateur d'erreurs aléatoires dans la classe ProductServiceImpl. Cette implémentation se fait en étendant le flux existant utilisé pour lire les informations produites à partir de la base de données MongoDB.

Voici à quoi cela ressemble :

 public Mono<Product> getProduct(int productId, int delay, 
  int faultPercent) {

  ...
  return repository.findByProductId(productId)
    .map(e -> throwErrorIfBadLuck(e, faultPercent))
    .delayElement(Duration.ofSeconds(delay))
    ...
}

Lorsque le flux renvoie une réponse du référentiel Spring Data, il applique d'abord la méthode throwErrorIfBadLuck pour déterminer si une exception doit être levée. Ensuite, il applique un délai en utilisant la fonction delayElement de la classe Mono.

Le générateur d'erreurs aléatoires, throwErrorIfBadLuck(), crée un nombre aléatoire entre 1 et 100, puis lève une exception s'il est supérieur ou égal au pourcentage d'erreur spécifié. Si aucune exception n'est levée, l'entité product est transmise dans le flux.

Voici à quoi ressemble le code source :

 private ProductEntity throwErrorIfBadLuck(
  ProductEntity entity, int faultPercent) {

  if (faultPercent == 0) {
    return entity;
  }

  int randomThreshold = getRandomNumber(1, 100);

  if (faultPercent < randomThreshold) {
    LOG.debug("We got lucky, no error occurred, {} < {}", 
      faultPercent, randomThreshold);
  
  } else {
    LOG.debug("Bad luck, an error occurred, {} >= {}",
      faultPercent, randomThreshold);
  
    throw new RuntimeException("Something went wrong...");
  }

  return entity;
}

private final Random randomNumberGenerator = new Random();

private int getRandomNumber(int min, int max) {

  if (max < min) {
    throw new IllegalArgumentException("Max must be greater than min");
  }

  return randomNumberGenerator.nextInt((max - min) + 1) + min;

Maintenant que les exceptions ont été implémentées, nous sommes prêts à ajouter des mécanismes de résilience au code. Nous allons commencer par mettre en place le disjoncteur et le limiteur de temps.

Ajout d'un disjoncteur et d'un limiteur de temps

Comme mentionné précédemment, pour mettre en place un disjoncteur et un limiteur de temps, nous devons ajouter des dépendances aux bibliothèques Resilience4j appropriées dans le fichier de construction build.gradle.

En plus de cela, nous devrons ajouter des annotations et une configuration spécifique, ainsi que du code pour implémenter une logique de secours pour les scénarios de défaillance rapide.

 ext {
   resilience4jVersion = "1.7.0"
}
dependencies {
    implementation "io.github.resilience4j:resilience4j-spring-
boot2:${resilience4jVersion}"
    implementation "io.github.resilience4j:resilience4j-reactor:${resilience4jVersion}"
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    ...

Afin d'éviter que Spring Cloud ne remplace la version de Resilience4j que nous souhaitons utiliser avec une ancienne version incluse dans sa propre gestion de dépendances, nous devons lister tous les sous-projets que nous voulons également utiliser et spécifier explicitement la version à utiliser.

Nous ajoutons cette dépendance supplémentaire dans la section dependencyManagement pour souligner qu'il s'agit d'une solution de contournement nécessaire en raison de la gestion des dépendances de Spring Cloud :

 dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
    dependencies {
        dependency "io.github.resilience4j:resilience4j-spring:${resilience4jVersion}" 
        ...
    }
}

Pour appliquer un disjoncteur, nous pouvons annoter la méthode qu'il est censé protéger avec @CircuitBreaker(...). Dans ce cas, la méthode getProduct() de la classe ProductCompositeIntegration est celle qui doit être protégée par le disjoncteur. Le disjoncteur est déclenché par une exception et non par un délai d'attente lui-même.

Si nous voulons déclencher le disjoncteur après un délai d'attente, nous devrons ajouter un limiteur de temps qui peut être appliqué avec l'annotation @TimeLimiter(...). Voici à quoi ressemble le code source :

 @TimeLimiter(name = "product")
@CircuitBreaker(
     name = "product", fallbackMethod = "getProductFallbackValue")

public Mono<Product> getProduct(
  int productId, int delay, int faultPercent) {
  ...
}

L'annotation name du disjoncteur et du limiteur de temps, "product", permet d'identifier la configuration qui sera appliquée. Le paramètre fallback de la méthode annotée avec @CircuitBreaker est utilisé pour spécifier la méthode de secours à appeler, getProductFallbackValue dans ce cas, lorsque le disjoncteur est ouvert. Pour en savoir plus sur son utilisation, il est recommandé de se référer à la documentation de Resilience4j.

Pour activer le disjoncteur, la méthode annotée doit être invoquée en tant que bean Spring. Dans notre cas, c'est la classe d'intégration qui est injectée par Spring dans la classe d'implémentation de service ProductCompositeServiceImpl et donc utilisée comme bean Spring :

 private final ProductCompositeIntegration integration;

@Autowired
public ProductCompositeServiceImpl(... ProductCompositeIntegration integration) {
  this.integration = integration;
}

public Mono<ProductAggregate> getProduct(int productId, int delay, int faultPercent) {
  return Mono.zip(
    ..., 
    integration.getProduct(productId, delay, faultPercent), 
    ...

Ajout d'une logique de secours rapide

Pour pouvoir appliquer une logique de repli lorsque le disjoncteur est ouvert, c'est-à-dire lorsque la requête échoue rapidement, nous pouvons spécifier une méthode de secours sur l'annotation @CircuitBreaker comme indiqué dans le code source précédent.

La méthode de secours doit suivre la signature de la méthode pour laquelle le disjoncteur est appliqué et avoir également un dernier argument supplémentaire utilisé pour transmettre l'exception qui a déclenché le disjoncteur. Dans notre cas, la signature de la méthode de secours ressemble à ceci :

 private Mono<Product> getProductFallbackValue(int productId, 
  int delay, int faultPercent, CallNotPermittedException ex) {

Le dernier paramètre spécifie que nous souhaitons pouvoir gérer les exceptions de type CallNotPermittedException. Nous ne nous intéressons qu'aux exceptions levées lorsque le disjoncteur est dans son état ouvert, afin que nous puissions appliquer une logique de défaillance rapide. Lorsque le disjoncteur est ouvert, il n'autorise pas les appels à la méthode sous-jacente ; à la place, il lèvera immédiatement une exception CallNotPermittedException. Par conséquent, nous ne nous intéressons qu'aux exceptions de type CallNotPermittedException.

La logique de secours peut rechercher des informations basées sur productId à partir de sources alternatives, telles qu'un cache interne. Dans notre cas, nous renverrons des valeurs codées en dur basées sur le productId, pour simuler un succès dans un cache. Pour simuler un échec dans le cache, nous lèverons une exception NotFoundException dans le cas où le productId est 13. L'implémentation de la méthode de secours ressemble à ceci :

 private Mono<Product> getProductFallbackValue(int productId, 
  int delay, int faultPercent, CallNotPermittedException ex) {

  if (productId == 13) {
    String errMsg = "Product Id: " + productId 
      + " not found in fallback cache!";
    throw new NotFoundException(errMsg);
  }

  return Mono.just(new Product(productId, "Fallback product" 
    + productId, productId, serviceUtil.getServiceAddress()));
}

Enfin, la configuration du disjoncteur et du limiteur de temps est ajoutée au fichier product-composite.yml dans le référentiel de configuration. La configuration peut ressembler à ceci :

 resilience4j.timelimiter:
  instances:
    product:
      timeoutDuration: 2s

management.health.circuitbreakers.enabled: true

resilience4j.circuitbreaker:
  instances:
    product:
      allowHealthIndicatorToFail: false
      registerHealthIndicator: true
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 5
      failureRateThreshold: 50
      waitDurationInOpenState: 10000
      permittedNumberOfCallsInHalfOpenState: 3
      automaticTransitionFromOpenToHalfOpenEnabled: true
      ignoreExceptions:
        - se.magnus.api.exceptions.InvalidInputException
        - se.magnus.api.exceptions.NotFoundException

Les sections précédents ont déjà décrit les valeurs de la configuration pour le disjoncteur et le limiteur de temps.

Ajout d'un mécanisme de nouvelle tentative

Tout comme pour le disjoncteur, le mécanisme de nouvelle tentative peut être implémenté en ajoutant des dépendances, des annotations et une configuration. Pour appliquer ce mécanisme à une méthode spécifique, il suffit de l'annoter avec @Retry(name="nnn"), où nnn est le nom de l'entrée de configuration à utiliser pour cette méthode. Dans notre cas, la méthode à utiliser est getProduct() dans la classe ProductCompositeIntegration.

  @Retry(name = "product")
  @TimeLimiter(name = "product")
  @CircuitBreaker(name = "product", fallbackMethod =
    "getProductFallbackValue")
  public Mono<Product> getProduct(int productId, int delay, 
    int faultPercent) {

La configuration du mécanisme de nouvelle tentative est ajoutée de la même manière que pour le disjoncteur et le limiteur de temps dans le fichier product-composite.yml du référentiel de configuration. Voici un exemple de configuration :

 resilience4j.retry:
  instances:
    product:
      maxAttempts: 3
      waitDuration: 1000
      retryExceptions:
      - org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError

Voilà toutes les dépendances, annotations, code source et configuration nécessaires. Pour conclure, nous pouvons étendre le script de test en y ajoutant des tests qui vérifient le bon fonctionnement du disjoncteur dans un environnement de système déployé.

Tests automatisés

Afin de réaliser certaines vérifications requises, nous avons besoin d'accéder aux points de terminaison actuator du microservice product-composite, qui ne sont pas exposés via le serveur Edge. Pour y accéder, nous allons exécuter une commande curl dans le conteneur product-composite en utilisant la commande Docker Compose exec. Comme l'image de base utilisée par les microservices contient curl, nous pouvons utiliser cette commande pour obtenir les informations nécessaires. Voici un exemple de commande pour extraire l'état du disjoncteur :

 docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state

Cette commande renverra l'état actuel du disjoncteur, qui peut être CLOSED, OPEN ou HALF_OPEN. Dans le script de test, nous commençons par vérifier que le disjoncteur est fermé avant de lancer les tests à l'aide de la commande ci-dessus.

Ensuite, pour forcer le disjoncteur à s'ouvrir, nous exécutons trois commandes d'affilée qui échouent toutes en raison d'un délai d'attente causé par une réponse lente du service product (le paramètre delay est défini sur 3 secondes). Voici un exemple de commande utilisée dans le script de test :

 for ((n=0; n<3; n++))
do
    assertCurl 500 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS?delay=3 $AUTH -s"
    message=$(echo $RESPONSE | jq -r .message)
    assertEqual "Did not observe any item or terminal signal within 2000ms" "${message:0:57}"
done

Cette commande échouera trois fois d'affilée et forcera le disjoncteur à s'ouvrir.

Après avoir forcé le disjoncteur à s'ouvrir, nous nous attendons à un comportement rapide et à l'appel de la méthode fallback pour renvoyer une réponse. Nous pouvons vérifier cela avec le code suivant dans le script de test :

 assertEqual "OPEN" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)"

assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS?delay=3 $AUTH -s"
assertEqual "Fallback product$PROD_ID_REVS_RECS" "$(echo "$RESPONSE" | jq -r .name)"

assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $AUTH -s"
assertEqual "Fallback product$PROD_ID_REVS_RECS" "$(echo "$RESPONSE" | jq -r .name)"

Nous pouvons également vérifier que la méthode de secours renvoie une erreur 404 NOT_FOUND pour un ID de produit non existant (par exemple, ID 13) :

 assertCurl 404 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_NOT_FOUND $AUTH -s"
assertEqual "Product Id: $PROD_ID_NOT_FOUND not found in fallback cache!" "$(echo $RESPONSE | jq -r .message)"

Selon la configuration, le disjoncteur passera à l'état semi-ouvert après quelques secondes. Pour vérifier cela, le test attend quelques secondes avant de continuer :

 echo "Will sleep for 10 sec waiting for the CB to go Half Open..."
sleep 10

Après avoir vérifié l'état attendu (semi-ouvert), le test effectue trois demandes normales pour ramener le disjoncteur à son état normal, qui est également vérifié :

 assertEqual "HALF_OPEN" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)"

for ((n=0; n<3; n++))
do
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $AUTH -s"
assertEqual "product name C" "$(echo "$RESPONSE" | jq -r .name)"
done

assertEqual "CLOSED" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)"

Le test vérifie également que la réponse contient les données de la base de données sous-jacente en comparant le nom du produit renvoyé avec la valeur stockée dans la base de données. Par exemple, pour le produit avec l'ID 1, le nom doit être "product name C".

Le test se termine en utilisant l'API /actuator/circuitbreakerevents de l'actionneur, qui est exposée par le disjoncteur pour révéler les événements internes. Cette API permet de connaître les transitions d'état effectuées par le disjoncteur. Pour vérifier que les trois dernières transitions d'état sont celles attendues (c'est-à-dire, fermé à ouvert, ouvert à semi-ouvert et semi-ouvert à fermé), le test utilise le code suivant :

 assertEqual "CLOSED_TO_OPEN" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r .circuitBreakerEvents[-3].stateTransition)"

assertEqual "OPEN_TO_HALF_OPEN" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r .circuitBreakerEvents[-2].stateTransition)"

assertEqual "HALF_OPEN_TO_CLOSED" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r .circuitBreakerEvents[-1].stateTransition)"

Ces commandes permettent de vérifier que les transitions d'état du disjoncteur ont eu lieu comme prévu.

Nous avons ajouté plusieurs étapes au script de test, mais cela permet de vérifier automatiquement que le comportement de base attendu de notre disjoncteur est en place. Dans la section suivante, nous allons essayer le disjoncteur en exécutant des tests automatiques à l'aide du script de test, ainsi qu'en exécutant manuellement les commandes du script de test.

Vérifier que le circuit est fermé en fonctionnement normal

Avant de pouvoir appeler l'API, il est nécessaire d'acquérir un jeton d'accès en exécutant les commandes suivantes :

 unset ACCESS_TOKEN
ACCESS_TOKEN=$(curl -k https://writer:secret@localhost:8443/oauth2/token -d grant_type=client_credentials -s | jq -r .access_token)
echo $ACCESS_TOKEN

Une fois le jeton d'accès acquis, vous pouvez effectuer une requête normale en utilisant le code suivant pour vérifier qu'elle renvoie le code de réponse HTTP 200 :

 curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1 -w "%{http_code}\n" -o /dev/null -s

Pour vérifier que le disjoncteur est fermé, vous pouvez utiliser l'API health avec la commande suivante :

 docker-compose exec product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state

Vous devez attendre que la réponse soit CLOSED.

Conclusion

En résumé, nous avons vu que Resilience4j est une bibliothèque Java permettant de construire des microservices résilients en ajoutant des fonctionnalités telles que le disjoncteur, le limiteur de temps et le mécanisme de nouvelle tentative. Le disjoncteur permet de gérer les services qui ne répondent pas en fermant le circuit lorsque la limite est atteinte, tandis que le limiteur de temps permet de maximiser le temps d'attente avant que le disjoncteur ne se déclenche. Le mécanisme de nouvelle tentative peut réessayer les demandes qui échouent de manière aléatoire de temps à autre. Il est important de ne l'appliquer qu'aux services idempotents. Les disjoncteurs et les mécanismes de nouvelle tentative sont implémentés en suivant les conventions Spring Boot, et Resilience4j expose des informations sur leur intégrité, leurs événements et leurs métriques via les points de terminaison actuator.

Developpeur et architecte passionné, qui souhaite partagé son univers et ses découvertes afin de rendre les choses plus simple pour chacun

URLs

Check les divers liens pour cet article