Java : spring-retry à votre secours pour faire des retry facilement et proprement

Introduction

Dans toutes les applications, en particulier dans les architectures micro-services, nous avons besoin de réaliser des retry lorsqu'une erreur s'est produite sur un appel HTTP. C'est plutôt simple à écrire, ce qui fait que nous nous retrouvons avec beaucoup d'applications comportant ce genre de code :

int retries = 0;
boolean success = false;
while(!success && retries <= 3) {
	try {
		// le code pouvant générer des erreurs
		success = true;
	} catch (Exception e) {
		retries++;
		Thread.sleep(5000);
	}
}
Exemple de retry

Cela fonctionne parfaitement mais le code est alourdi pour un simple retry. Et dans cet exemple, une pause fixe de 5 secondes est réalisée, ce qui reste un cas simpliste. Dans le cas d'appel réseau en erreur, il est conseillé de faire des pauses exponentielles pour éviter de surcharger inutilement un réseau déjà mal au point...

C'est à ce moment qu'entre en jeu spring-retry. Plutôt que d'écrire tout cela à la main, spring-retry fourni tout ce qu'il faut pour ajouter des retry sur nos méthodes. Il est très largement éprouvé puisque utilisé de manière interne par Spring Batch et Spring Integration.

Pour illustrer l'utilisation de spring-retry, nous allons partir d'un projet très simple : une application Spring Boot avec un unique Endpoint qui retourne "OK".


@SpringBootApplication
@RestController
@Slf4j
public class SpringRetrySampleApplication {

	@Autowired
    private SomeService service;
	
    @RequestMapping("/get")
    public String get() {
        return service.execute();
    }
	
	public static void main(String[] args) {
		SpringApplication.run(SpringRetrySampleApplication.class, args);
	}	
}

@Service
@Slf4j
public class SomeService {
    @Retryable
    public String execute() {
        log.info("Executing...");

        return "OK";
    }
}
Squelette de l'application d'exemple

 

En l'état, cette application retourne donc toujours OK et une ligne de log "Executing..." est affichée. Nous allons voir comment ajouter du retry facilement sur la méthode execute() en prenant l'hypothèse qu'elle sera responsable de réaliser des appels HTTP.

Ajout de la dépendance

Commençons tout d'abord par ajouter la dépendance à votre projet :

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
    <version>1.2.1.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>1.8.10</version>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.8.10</version>
</dependency>
Dépendance avec maven
compile('org.springframework.retry:spring-retry')
compile("org.aspectj:aspectjrt")
compile("org.aspectj:aspectjweaver")
Dépendance avec Gradle

La dépendance vers aspectj est optionnelle mais nécessaire dès lors que l'on souhaite utiliser la configuration par annotation.

Utilisation par annotation

La manière la plus simple d'utiliser spring-retry est la configuration via annotation. Nous verrons plus loin que l'on peut également l'utiliser directement via RetryTemplate.

Maintenant que spring-retry est dans notre classpath, il suffit de l'activer via l'annotation @EnableRetry.

@SpringBootApplication
@RestController
@Slf4j
@EnableRetry
public class SpringRetrySampleApplication {

}
Activation de SpringRetry

Nous pouvons ensuite définir les méthodes sur lesquelles effectuer des retry, par exemple notre méthode execute() si elle lève une SocketTimeoutException.

@SpringBootApplication
@RestController
@Slf4j
@EnableRetry
public class SpringRetrySampleApplication {

    @Autowired
    private SomeService service;

    @RequestMapping("/get")
    public String get() {
        String result = "KO";

        try {
            result = service.execute();
        } catch(Exception e) {
            log.error("Erreur !", e);
        }

		log.debug("Le résultat est : {}", result);
		
        return result;
    }

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

@Service
@Slf4j
public class SomeService {
    @Retryable
    public String execute() throws SocketTimeoutException {
        log.info("Executing...");

        throw new SocketTimeoutException("Erreur !");
    }
}

En effectuant un appel sur le endpoint /get, nous observons maintenant que la console affiche 3 fois la ligne de log, puis seulement ensuite l'exception est levée. Il est également possible de définir le nombre de tentatives à effectuer ainsi que d'autres options via l'annotation @Retryable :

  • include : liste des exceptions à intercepter
  • exclude : liste des exceptions à ne pas intercepter
  • maxAttempts : nombre de tentatives à effectuer (incluant la première tentative)
  • maxAttempsExpression : permet de définir le nombre de tentatives grâce à une expression SpEL
  • backoff : permet d'ajouter des pauses entre les tentatives

Par exemple, il est au courant de configurer un exponential backoff pour ajouter des temps de pause exponentiels entre chaque tentative afin d'éviter de surcharger un serveur déjà ralenti par exemple. Voici comment procéder.

@Service
@Slf4j
public class SomeService {
    @Retryable(maxAttempts = 10, backoff = @Backoff(delay = 1000, maxDelay = 10000, multiplier = 2))
    public String execute() throws SocketTimeoutException {
        log.info("Executing...");

        throw new SocketTimeoutException("Erreur !");
    }
}

Voici le sortie console dans ce cas, nous voyons bien dans ce cas que les temps de pause sont multipliés par deux entre chaque essai, jusqu'à atteindre un maximum de 10s :

2017-06-26 21:39:12.270  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:13.272  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:15.273  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:19.274  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:27.276  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:37.277  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:47.278  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:39:57.279  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:40:07.280  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:40:17.282  INFO 28794 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 21:40:17.292 ERROR 28794 --- [nio-8080-exec-1] o.b.s.SpringRetrySampleApplication       : Erreur !

Il est également possible d'utiliser l'annotation @Recover pour executer une méthode lorsque la méthode @Retryable est en échec. Attention, la méthode annotée doit respecter certaines contraintes :

  • @Recover et @Retryable doivent être définies dans la même classe
  • @Recover doit retourner le même type que @Retryable
  • @Recover peut éventuellement avoir des paramètres:
    • le premier est l'exception levée par la méthode @Retryable
    • les suivants sont les paramètres envoyés initialement à la méthode @Retryable (dans le même ordre, avec le même nom et le même type)

Voici un exemple d'utilisation :

@Service
@Slf4j
public class SomeService {
    @Retryable
    public String execute() throws SocketTimeoutException {
        log.info("Executing...");

        throw new SocketTimeoutException("Erreur !");
    }

    @Recover
    public String recover(SocketTimeoutException e) {
        log.info("Recover after exception : {}", e.getMessage());

        return "RECOVERED";
    }
}

Qui écrit ceci sur la console :

 

2017-06-26 22:07:05.089  INFO 29179 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 22:07:06.090  INFO 29179 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 22:07:07.091  INFO 29179 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Executing...
2017-06-26 22:07:07.092  INFO 29179 --- [nio-8080-exec-1] o.boudet.springretrysample.SomeService   : Recover after exception : Erreur ! 
2017-06-26 22:07:07.092  INFO 29179 --- [nio-8080-exec-1] o.b.s.SpringRetrySampleApplication       : Le résultat est : RECOVERED

Utilisation par RetryTemplate

Dans les cas où il n'est pas possible d'utiliser les annotations (avant l'initialisation du contexte Spring ou en dehors de beans Spring), il est possible d'utiliser la classe RetryTemplate pour définir une politique de retry de manière programmatique.

L'exemple suivant montre ainsi comment il est possible d'utiliser RetryTemplate dans une méthode @PostContruct.

    @PostConstruct
    public void init() {
        RetryTemplate retryTemplate = new RetryTemplate();

        RetryPolicy retryPolicy = new SimpleRetryPolicy(4, Collections.singletonMap(RuntimeException.class, true));
        ExponentialBackOffPolicy backoff = new ExponentialBackOffPolicy();
        backoff.setInitialInterval(100);
        backoff.setMultiplier(2);
        retryTemplate.setRetryPolicy(retryPolicy);
        retryTemplate.setBackOffPolicy(backoff);

        retryTemplate.execute(new RetryCallback<Void, RuntimeException>() {
            @Override
            public Void doWithRetry(RetryContext context) {
                throw new RuntimeException("erreur !");
            }
        }, new RecoveryCallback<Void>() {
            @Override
            public Void recover(RetryContext context) throws Exception {
                log.error("Erreur dans l'init après {} essais !",  context.getRetryCount(), context.getLastThrowable());
                return null;
            }
        });
    }

A la ligne 5 de cet exemple, un objet SimpleRetryPolicy est instancié avec le nombre d'essais total souhaité, ainsi que les exceptions à prendre en compte ou non.

Les lignes suivantes permettent d'initialiser la politique de backoff, c'est à dire les pauses à réaliser entre les retry.

L'objet RetryContext accessible dans les méthodes doWithRetry() et recover() permet de récupérer le nombre courant d'essais réalisés ainsi que la dernière exception rencontrée.

Utilisation par XML

Comme chaque projet spring, il est également possible de configurer spring-retry par XML. Je ne détaille pas ici la marche à suivre, je vous laisse consulter la documentation.

Le mot de la fin

C'est aussi simple que cela... A adopter au plus vite dans vos projets !

Pour plus de détails sur l'implémentation, vous pourrez consulter la Javadoc.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *