Spring Cloud Feign et la gestion des erreurs

Feign, Hystrix, Ribbon, Eureka, de supers outils parfaitement packagés dans Spring Cloud Feign, pour apporter de la résilience dans nos applications distribuées ! Quelques imports et annotations suffisent maintenant pour construire des architectures antifragiles. Mais est-ce vraiment aussi simple ? Bien sûr que non ! Ces outils nous apportent énormément. Il faut cependant prendre le temps de les comprendre pour les utiliser correctement et obtenir les résultats voulus.

Cet article ne traite pas des bases de l’utilisation de ces outils. Je ne fais que pointer du doigt certains problèmes que j’ai pu rencontrer et je partage des solutions (il en existe sûrement de meilleures, mieux adaptées à vos contextes).

Si vous cherchez un getting started sur ces outils, vous pouvez jeter un œil à :

Cet article contient du code, mais pas tant que ça. Vous pouvez trouver le reste dans ce repository.

Les dépendances

Bien ! Après quelques essais, vous voilà avec ce jeu de dépendances Maven :

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
 
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-hystrix</artifactId>
</dependency>
 
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-feign</artifactId>
</dependency>
 
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-ribbon</artifactId>
</dependency>
 
<dependency>
  <groupId>org.springframework.retry</groupId>
  <artifactId>spring-retry</artifactId>
</dependency>

Ok, vous voulez utiliser toute la stack :

  • Bien sûr, vous allez utiliser Eureka client pour récupérer les instances de vos services depuis le serveur Eureka.
  • Comme ça, Ribbon pourra faire du load-balancing côté client en utilisant le nom des services et pas les URLs (il pourra aussi décorer les RestTemplate pour permettre l’utilisation des noms et du load-balancing).
  • Ensuite, Hystrix, avec tous ses patterns antifragiles, est un très bel outil. Mais il demande une surveillance rapprochée (ce n'est pas le sujet de cet article).
  • Enfin, Feign permet la création très simple de clients REST utilisant la puissance de tous ces outils.

Cet article a été écrit en utilisant ces versions :

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>1.5.10.RELEASE</version>
</parent>
 
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>Dalston.SR5</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Configuration

Bien ! Il faut maintenant configurer un peu tout ça... Admettons que vous finissiez avec quelque chose comme ça dans votre application.yml :

spring: 
  application:
    name: my-awesome-app
    
eureka:
  client:
    serviceUrl: 
      defaultZone: http://my-eureka-instance:port/eureka/
 
feign:
  hystrix:
    enabled: true
 
hystrix: 
  threadpool: 
    default: 
      coreSize: 15
  command: 
    default: 
      execution: 
        isolation: 
          strategy: THREAD
          thread: 
            timeoutInMilliseconds: 2000
 
ribbon: 
  ReadTimeout: 400
  ConnectTimeout: 100
  OkToRetryOnAllOperations: true
  MaxAutoRetries: 1
  MaxAutoRetriesNextServer: 1

Cette configuration sera fonctionnelle si votre application peut s’enregistrer sur Eureka en utilisant le nom de la machine où elle est déployée et le port de l’application. Pour la production / le cloud / n’importe quel environnement avec des proxys, vous avez une autre configuration avec :

  • eureka.instance.hostname avec le nom de la machine qui doit être utilisée pour questionner vos services,
  • eureka.instance.nonSecurePort avec le port non sécurisé qui doit être utilisé pour questionner vos services, ou eureka.instance.securePort avec eureka.instance.securePortEnabled=true.

Dans cette configuration, la connexion à Eureka n’est pas authentifiée. En fonction de votre environnement, cela peut être une bonne idée d’ajouter une authentification, même basic, pour la connexion à Eureka.

Ensuite, je vois à la configuration dans votre application.yml, que vous avez une totale confiance en vos services : 400ms pour les ReadTimeout c’est court. Quand on parle d’appels de services, plus c’est court, mieux c’est !

Je vois aussi que tous vos services sont idempotents car vous acceptez d’avoir 4 appels plutôt qu’un seul en cas de défaillance d’un des services ou du réseau. Ces 4 appels potentiels viennent de la configuration de Ribbon, puisque le nombre d’appels qui peuvent être faits est de : (1 + MaxAutoRetries) x (1 + MaxAutoRetriesNextServer). Cela veut dire qu’avec une configuration avec 2 et 3, vous pouvez avoir jusqu'à 12 appels uniquement depuis la couche Ribbon.

Ces potentiels appels et les temps configurés nous amènent aux 2 000ms du timeout Hystrix. Une valeur moindre entraînerait des appels faits depuis les commandes Hystrix, alors que votre application a repris la main (et qu’elle ne peut donc pas voir le résultat de ces appels).

Personnalisation

Jusque-là, tout va bien ! Vous comprenez rapidement que, pour chaque FeignClient sans fallback, vous ne récupérez que des HystrixRuntimeException quel que soit le type d’erreur rencontrée. Cette exception indique essentiellement que quelque chose s’est mal passé et que le client n’a pas de fallback configuré. Cependant, la cause de cette exception peut vous donner des informations intéressantes sur la véritable erreur rencontrée. Vous construisez rapidement un ExceptionHandler vous permettant de renvoyer des messages adaptés aux utilisateurs (parce que vous ne voulez pas mettre de fallbacks sur tous vos FeignClient).

Votre application fonctionne bien, vos services se parlent correctement. Puis un jour, vous devez appeler un nouveau service externe. Ce service peut renvoyer des HTTP 404 pour certaines ressources. Vous décidez de traiter ça simplement en ajoutant decode404 = true dans votre @FeignClient, pour récupérer une réponse dans ce cas et éviter le circuit breaking dans les cas "normaux" (si vous ne mettez pas cette option, les réponses en 404 comptent dans le calcul des ouvertures des cricuit breakers). Cependant, contrairement à ce que vous attendiez, vous ne récupérez pas de réponses, mais des exceptions :

...
Caused by: feign.codec.DecodeException: Could not extract response: no suitable HttpMessageConverter found for response type [class ...
...

Cela vient du fait que les contenus des réponses en 404 sont différents des réponses “normales” (souvent une simple chaîne de caractères indiquant que la ressource n’existe pas). Une idée pourrait être de permettre le décodage des réponses dans des Optional<?> ou des ResponseEntity<?> dans les FeignClient et de récupérer des body vides pour les 404.

Les clients Spring Cloud Feign configurés automatiquement permettent le mapping dans des ResponseEntity<?>. Mais la désérialisation des réponses échouera si les objets ne sont pas compatibles. Ces clients ne peuvent pas, par défaut, renseigner des Optional<?> donc ajouter cette fonctionnalité reste une bonne idée.

Une manière de faire cela est de définir un Decoder qui peut ressembler à ça :

package fr.ippon.feign;

import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Optional;

import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;

import feign.FeignException;
import feign.Response;
import feign.Util;
import feign.codec.DecodeException;
import feign.codec.Decoder;

public class NotFoundAwareDecoder implements Decoder {

  private final Decoder delegate;

  public NotFoundAwareDecoder(Decoder delegate) {
    Assert.notNull(delegate, "Can't build this decoder with a null delegated decoder");

    this.delegate = delegate;
  }

  @Override
  public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
    if (!(type instanceof ParameterizedType)) {
      return delegate.decode(response, type);
    }

    if (isParameterizedTypeOf(type, Optional.class)) {
      return decodeOptional(response, type);
    }

    if (isParameterizedTypeOf(type, ResponseEntity.class)) {
      return decodeResponseEntity(response, type);
    }

    return delegate.decode(response, type);
  }

  private boolean isParameterizedTypeOf(Type type, Class<?> clazz) {
    ParameterizedType parameterizedType = (ParameterizedType) type;

    return parameterizedType.getRawType().equals(clazz);
  }

  private Object decodeOptional(Response response, Type type) throws IOException {
    if (response.status() == 404) {
      return Optional.empty();
    }

    Type enclosedType = Util.resolveLastTypeParameter(type, Optional.class);
    Object decodedValue = delegate.decode(response, enclosedType);

    if (decodedValue == null) {
      return Optional.empty();
    }

    return Optional.of(decodedValue);
  }

  private Object decodeResponseEntity(Response response, Type type) throws IOException {
    if (response.status() == 404) {
      return ResponseEntity.notFound().build();
    }

    return delegate.decode(response, type);
  }
}

Puis, dans une classe de configuration, de configurer l’utilisation de ce Decoder :

package fr.ippon.feign;
 
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.web.HttpMessageConverters;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.feign.EnableFeignClients;
import org.springframework.cloud.netflix.feign.support.ResponseEntityDecoder;
import org.springframework.cloud.netflix.feign.support.SpringDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
 
import feign.codec.Decoder;
 
@Configuration
@EnableCircuitBreaker
@EnableDiscoveryClient
public class FeignConfiguration {
 
  @Bean
  public Decoder notFoundAwareDecoder(ObjectFactory<HttpMessageConverters> messageConverters) {
    return new NotFoundAwareDecoder(new ResponseEntityDecoder(new SpringDecoder(messageConverters)));
  }
  
}

Bien sûr, c’est à vous de modifier ces éléments pour qu’ils répondent à vos besoins.

Tests d’intégration

D’une version mineure de Spring Cloud à une autre, il peut y avoir d’importants changements sur le comportement de ces librairies, notamment sur les configurations par défaut (ex : Hystrix activé par défaut à Hystrix désactivé par défaut) donc, à moins que vous ne manquiez aucun changement (je ne pense pas que ce soit humainement possible), je vous recommande vivement d’ajouter des tests d’intégration pour vous assurer que les fonctionnalités que vous utilisez sur cette stack continuent de fonctionner comme vous l’attendez. Malheureusement, dans ce cas, des tests unitaires ne permettront pas de s’assurer du fonctionnement en cas de changement de version.

Mettre en place des tests d’intégration sur cette stack n’est pas aisé, il va nous falloir :

  • Un serveur Eureka.
  • Un service enregistré sur cette instance d’Eureka.
  • Un client utilisant ce service.

Une façon de faire cela est de créer un environnement contenant ces éléments, peut-être en utilisant des conteneurs, pour chaque campagne de test. Cependant, en fonction de l’organisation de la société dans laquelle vous êtes, cela ne sera pas toujours possible à mettre en place. Une autre manière est de tout charger dans la même JVM en acceptant le risque que des configurations automatiques viennent changer les comportements observés en production.

Pour mettre en place le chargement de tous ces éléments dans la même JVM (pour permettre un lancement simplifié avec JUnit), il va falloir résoudre quelques problèmes :

  • On ne pourra pas utiliser les fonctionnements natifs de SpringTest, car ils ne permettent la gestion que d’un seul contexte Spring par test (et donc une seule application). Ce n'est pas vraiment une difficulté, il suffit de démarrer nos applications en utilisant SpringApplication.run(...) et de manipuler les ConfigurableApplicationContext qui en résultent.
  • On va avoir besoin de démarrer nos applications sur des ports disponibles sur la machine. Chose facile : il suffit d’ajouter --server.port en paramètre de SpringApplication.run(...) avec une valeur venant de SocketUtils.findAvailableTcpPort().
  • On ne pourra pas utiliser les chemins de configuration par défaut, à moins de vouloir ces configurations sur toutes nos applications. Là encore, la solution est simple : il suffit d’ajouter --spring.config.location en paramètre de lancement en spécifiant la configuration que l’on veut utiliser.
  • La configuration de nos applications et de nos clients va devoir s’adapter au démarrage de l’instance de Eureka qui va démarrer sur un port disponible de la machine. Il va falloir s’assurer que le service Eureka est le premier à démarrer et faire passer le port de Eureka dans les configurations de nos autres applications. Ce besoin d’avoir Eureka démarré avant les autres application n’est vrai que pour les tests. En production, les clients Eureka gèrent très bien les reconnexions.
  • Par défaut, Spring Boot instancie des ressources JMX (qui ne peuvent pas être en doublon) pour chaque application. Pour pouvoir démarrer plusieurs applications, il faut ajouter --spring.jmx.enabled=false dans la configuration de nos application pour ne plus avoir ces beans. Il est aussi possible de spécifier un --spring.jmx.default-domain pour chaque application si on veut garder les beans JMX.
  • Le dernier problème que l’on doit gérer est étrange : la configuration de Spring Cloud est gérée par Archaius et la configuration venant de Spring Boot n’est chargée dans Archaius que pour la première application démarrant sur la JVM. Nous allons donc devoir forcer le rechargement de cette configuration (du moins tant que le TODO qui est ici n’est pas traité). Pour ce faire nous allons nous permettre un patch bien peu élégant, uniquement pour les tests, en forçant le rechargement de cette configuration dans un ApplicationListener<ApplicationReadyEvent>.

J’ai mis en place ces mécaniques dans un repo Git, en utilisant essentiellement des JUnitRules. N’hésitez pas à reprendre et adapter ce code pour vos besoins.

Au moment où j’écris ces lignes, le projet met ~45 secondes à se construire. Ce qui est très long, surtout en considérant le fait qu’une très grande partie de ce temps de build est passée dans des tests d’intégration pour du code largement battle tested… Cependant, je veux vraiment éviter de manquer un changement dans une version de Spring qui rendrait l’intégration de ces outils obsolète. Donc je considère que le prix à payer n’est pas trop élevé.

Si vous voulez reprendre ce code vous pouvez supprimer toute la partie de tests sur les circuit breaking sur tous les codes HTTP, si vous n’en avez pas besoin. Ils sont très longs du fait des attentes de changements d’états des circuit breakers.

Encore une fois, prenez le temps de vous familiariser avec ces outils et assurez-vous de l’utilisation que vous faites de cette stack pour éviter de bien mauvaises surprises après quelques mois en production.

Pour aller plus loin

En fonction de ce que vous voulez faire, ce qui est mis en place ici peut être largement suffisant du côté applicatif. Cependant, pour partir en production, je ne saurais que trop vous conseiller de mettre en place un solide système de métriques et d’alertes sur ces éléments (à minima pour surveiller l’état des circuit breakers).

Pour ce faire vous pouvez jeter un œil à Hystrix dashboard et Turbine pour avoir énormement d’informations sur vos appels entre services.

Si vous choisissez cette stack, vous aurez besoin de la brancher sur votre système d’alerting. Cela va demander un peu de travail et vous devez vous préparer à gérer BEAUCOUP de données. Selon vos besoins, de simples metrics Counter sur les fallbacks peuvent largement suffire.

Il est aussi possible que les outils, dont il a été question ici, ne fournissent pas tous les patterns antifragiles que vous recherchez. Dans ce cas, vous pouvez commencer par jeter un œil :

  • Aux configurations Hystrix. Vous verrez que cet outil vous permet de configurer énormement de choses pour la résilience de vos applications (jouer avec la configuration des circuit breakers peut vraiment rendre de fiers services).
  • Au retries Feign. C’est une partie de Feign que j’ai totalement omise dans cet article. Feign permet une gestion de retries qui vient s’ajouter à celles proposées par Ribbon et Hystrix. Vous pouvez regarder Retryer.Default pour voir la stratégie par défaut, cependant, cela peut induire en erreur :
    • Tout d’abord, en utilisant Hystrix, le Retryer utilisé par défaut est Retryer.NEVER_RETRY (allez vérifier dans FeignClientsConfiguration.feignRetryer()).
    • Ensuite, même si vous définissez un @Bean Retryer en utilisant Retryer.Default, vous n’aurez surement pas de retries sur vos appels car ErrorDecoder.Default ne renvoie des RetryableException que s’il y a un header Retry-After contenant une date correctement formatée dans le header de la réponse.

Pour pouvoir utiliser des retries Feign vous devrez donc :

  • fournir un Bean de Retryer pour fournir une implémentation qui fasse réellement des retries,
  • et sûrement définir un Bean Feign.Builder (en faisant bien attention a garder le @Scope("prototype")).

Bon, on part en prod ?

Ces outils sont vraiment bien pensés. Les développeurs qui vont les utiliser au quotidien les apprécieront ; du moins, à partir du moment où quelqu’un aura passé quelques jours à mettre tout ça correctement en place et se sera assuré que la réponse au besoin est correcte. Il faut notamment s’assurer que :

  • L’on ne fasse pas de retries sur les POST et les PATCH (ou sur tout autre service qui ne soit pas idempotent, normalement en suivant la RFC 2616).
  • Les fallbacks et ouvertures de circuit breakers soient correctement suivis et expliqués.
  • Eureka ne soit pas un SPOF dans cette architecture et qu’il soit correctement sécurisé (si toutes les instances de Eureka tombent, les services continueront de discuter entre eux).
  • Une montée de version mineure d’un composant ne casse pas un fonctionnement dont dépend l’application.

AMHA cette stack demande du travail et de la compréhension pour être utilisée en production. Donc utilisez-la si vous en avez réellement le besoin. Sinon restez sur des RestTemplate en attendant de pouvoir sécuriser votre utilisation de ces outils.