Spring Retry

Por Gaspar Barancelli Junior em 22 de fevereiro de 2023

Quando trabalhamos com micro serviços estamos a todo momento fazendo chamadas a serviços externos, sejam eles por REST, SOAP, GRPC, RMI e tantas outras formas de integração.

Mas já parou para analisar quantas falhas acontecem nessas chamadas, e por motivos externos que não temos controle, um exemplo bem simples disso é que o serviço que desejamos consumir pode estar indisponível ou sobrecarregado no momento em que a requisição é feita.

Geralmente quando uma falha acontece nessas chamadas externas, sejam elas uma comunicação com banco de dados ou qualquer outro serviço, a nossa aplicação deve ser capaz de tentar fazer uma nova chamada a esses serviços externos em caso de uma falha desconhecida. Imagine que sua aplicação está fazendo um insert no banco de dados, mas que o banco tenha atingido o limite de conexões disponíveis ou que o serviço esteja indisponível, o ideal nesses casos não seria retornar um erro para o usuário, e sim aguardar um período e fazer uma nova tentativa de insert, podendo até mesmo fazer mais de uma tentativa e aumentando esse time a cada nova tentativa, aguardando com que o serviço externo seja normalizado. Mas caso um número X de tentativas forem realizadas e mesmo assim nossa aplicação não tenha sucesso ao se comunicar com o serviço externo, podemos executar um método de fallback, e neste método enviamos os dados para uma filha para que posteriormente possamos inseri-las no banco de dados.

Para facilitar toda essa implementação de tentativas e fallback é que surgiu o projeto Spring Retry, com poucas linhas de código podemos implementar todo o cenário explicado acima. A implementação pode ser utilizando anotações no qual o Spring utilizara AOP para implementação, mas também pode ser feita de forma imperativa.

Exemplos

Todos os exemplos deste post estão neste repositório no github.

Criar projeto

Vamos criar um projeto Spring Boot, para isso acesse o Spring Initializr e efetue o download do projeto, para este exemplo não vamos configurar nenhuma dependência pelo initializr.

tela do spring boot initializr

Descompacte o arquivo baixado e abra o projeto em sua IDE favorita.

Dependências

Adicione as seguintes dependências no pom.xml.

<dependency>
   <groupId>org.springframework.retry</groupId>
   <artifactId>spring-retry</artifactId>
</dependency>

<dependency>
   <groupId>org.springframework</groupId>
   <artifactId>spring-aspects</artifactId>
</dependency>

Configuração

Abra a sua classe main e adicione a anotação @EnableRetry.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication
@EnableRetry
public class RetryApplication {

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

}

Serviço Externo

Para melhorar o entendimento vamos criar uma classe simulando um serviço externo.

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ExternalService {

   public List<String> get() {
       throw new RuntimeException();
   }

}

A classe acima é um Service do Spring, que dispara uma exceção quando o método get for chamado. Com isso conseguimos simular um erro ao realizar uma chamada externa.

Demonstração utilizando AOP

Vamos criar uma classe que vai simular uma chamada ao serviço externo, mas que vai utilizar o Spring Retry, realizando uma nova tentativa de chamada ao serviço externo quando um RuntimeException for lançado.

Para realizar as novas tentativas de chamadas ao serviço externo, basta adicionar a seguinte anotação @Retryable(RuntimeException.class)

Nesta mesma classe também estamos utilizando o método de fallback, por padrão o método que contém a anotação de Retry vai realizar 3 tentativas e caso não obtenha sucesso pode chamar o método de fallback, mas este método deve conter a anotação Recover e deve ter o mesmo tipo de retorno e receber por parâmetro a exceção lançada pelo método que contém a anotação de retry.

import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;

@Service
class RetryDemoDeclarative {

   private final ExternalService externalService;

   public RetryDemoDeclarative(ExternalService externalService) {
       this.externalService = externalService;
   }

   @Retryable(RuntimeException.class)
   public List<String> retryable() {
       return externalService.get();
   }

   @Recover
   public List<String> recover(RuntimeException e) {
       return Collections.singletonList("blog");
   }

}

Segue a classe de teste da implementação acima.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;

@SpringBootTest
class RetryDemoDeclarativeTest {

   @SpyBean
   private RetryDemoDeclarative retryDemo;

   @Test
   void retryConfig() {
       List<String> list = retryDemo.retryable();
       verify(retryDemo).recover(any());
       assertEquals("blog", list.get(0));
   }

}

No teste acima injetamos a classe de implementação que faz uso de Retry, onde verificamos que o método de fallback foi executado com sucesso, e que o valor de retorno é exatamente o que está sendo retornado no método de fallback.

O método de fallback sempre é executado pois nossa classe que simula o serviço externo sempre retorna uma exceção.

Customizando o número de tentativas e tempo entre elas de forma estática

Para realizar essas customizações vamos editar apenas a anotação Retryable, adicionando valores nas propriedades maxAttempts e backoff.

Segue um exemplo de uma customização onde gostaríamos de realizar apenas 2 tentativas, e para cada nova tentativa o framework deve aguardar 1 segundo para execução do método.

@Retryable(
   value = RuntimeException.class,
   maxAttempts = 2,
   backoff = @Backoff(delay = 1000)
)

Para ficar um pouco mais claro a classe abaixo contém toda alteração realizada.

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RetryDemoDeclarativeCustomConfig {

   private final ExternalService externalService;

   public RetryDemoDeclarativeCustomConfig(ExternalService externalService) {
       this.externalService = externalService;
   }

   @Retryable(
           value = RuntimeException.class,
           maxAttempts = 2,
           backoff = @Backoff(delay = 1000)
   )
   public List<String> retryable() {
       return externalService.get();
   }

   @Recover
   public List<String> recover(RuntimeException e) {
       return List.of("gasparbarancelli");
   }

}

O exemplo acima também contém a seguinte classe de teste.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;

@SpringBootTest
class RetryDemoDeclarativeCustomConfigTest {

   @SpyBean
   private RetryDemoDeclarativeCustomConfig retryDemo;

   @Test
   void retryConfig() {
       List<String> list = retryDemo.retryable();
       verify(retryDemo).recover(any());
       assertEquals("gasparbarancelli", list.get(0));
   }

}

Assim como no primeiro teste que executamos, verificamos que o método de fallback é executado e garantimos que o retorno é o mesmo do método de fallback.

Customizando o número de tentativas e tempo entre elas de forma dinamica

O ideal é configurar o número de tentativas e o delay entre elas por meio de properties, para que seja simples efetuar uma alteração quando necessário, até mesmo porque podemos ter valores diferentes por ambientes de desenvolvimento, homologação e produção.

Vamos adicionar duas novas propriedades no arquivo application.properties e vincular as mesmas na configuração da nossa anotação @Retryable.

config.retry.maxAttempts=4
config.retry.maxDelay=1000

Agora podemos de fato alterar nosso código para que ele seja capaz de ler essas properties.

@Retryable(
   value = RuntimeException.class,
   maxAttemptsExpression = "${config.retry.maxAttempts}",
   backoff = @Backoff(delayExpression = "${config.retry.maxDelay}")
)

Preste bem atenção pois os nomes das propriedades foram alterados de maxAttempts para maxAttemptsExpression e delay para delayExpression, agora sim o Spring Retry vai resolver esses valores, lendo do nosso arquivo de properties.

Abaixo segue uma nova classe contendo toda essa customização e também nossa classe de testes.

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;

@Service
public class RetryDemoDeclarativeCustomConfigWithProperties {

   private final ExternalService externalService;

   public RetryDemoDeclarativeCustomConfigWithProperties(ExternalService externalService) {
       this.externalService = externalService;
   }

   @Retryable(
           value = RuntimeException.class,
           maxAttemptsExpression = "${config.retry.maxAttempts}",
           backoff = @Backoff(delayExpression = "${config.retry.maxDelay}")
   )
   public List<String> retryable() {
       return externalService.get();
   }

   @Recover
   public List<String> recover(RuntimeException e) {
       return Collections.emptyList();
   }

}

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;

@SpringBootTest
class RetryDemoDeclarativeCustomConfigWithPropertiesTest {

   @SpyBean
   private RetryDemoDeclarativeCustomConfigWithProperties retryDemo;

   @Test
   void retryConfig() {
       List<String> list = retryDemo.retryable();
       verify(retryDemo).recover(any());
       assertNotNull(list);
       assertEquals(0, list.size());
   }

}

Sobrescrevendo os valores padrões

Como utilizamos Spring Boot as dependências são auto configuráveis, mas podemos customizá-las mesmo assim, portanto a classe a seguir é um exemplo de customização e não necessariamente você precisa adicionar em seu projeto.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.FixedBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

@Configuration
public class RetryConfig {

   @Value("${config.retry.maxAttempts}")
   private int maxAttempts;

   @Value("${config.retry.maxDelay}")
   private long maxDelay;

   @Bean
   public RetryTemplate retryTemplate() {
       RetryTemplate retryTemplate = new RetryTemplate();

       FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();
       fixedBackOffPolicy.setBackOffPeriod(maxDelay);
       retryTemplate.setBackOffPolicy(fixedBackOffPolicy);

       retryTemplate.setRetryPolicy(new SimpleRetryPolicy(maxAttempts));

       return retryTemplate;
   }

}

Especificamos que nossa classe acima é de configuração adicionando a seguinte anotação Configuration e criamos um Bean do tipo RetryTemplate, nesse Bean nos criamos um objeto do tipo RetryTemplate e configuramos o número de tentativas e do delay entre as tentativas.

Com essa configuração todas as implementações de Retry que não customizarem as propriedades de número de tentativas e delay utilizaram os valores da nossa classe de configuração.

Demonstração de forma imperativa

Caso queira utilizar o Spring Retry de forma imperativa basta criar um objeto do tipo RetryTemplate e o configurá-lo assim como realizamos com a anotação @Retryable.

import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RetryDemoImperative {

   private final ExternalService externalService;

   public RetryDemoImperative(ExternalService externalService) {
       this.externalService = externalService;
   }

   public List<String> retryable() {
       RetryTemplate template = RetryTemplate.builder()
               .maxAttempts(3)
               .fixedBackoff(1000)
               .retryOn(RuntimeException.class)
               .build();

       return template.execute(
               ctx -> externalService.get(),
               ctx -> List.of("blog", "gasparbarancelli")
       );
   }

}

Considerações finais

É muito importante realizar uma configuração adequada a cada chamada de serviço externo, para isso deve-se fazer o uso de monitoria e observabilidade das aplicações.

// Livros recomendados relacionados ao assunto do post

// Compartilhe esse Post