Retry com Resilience4j: quando tentar de novo piora tudo
O retry junto do circuit breaker no mesmo exemplo em Spring Boot 3, e quatro jeitos de ele deixar a falha maior.
·6 min de leitura#resiliência#java#spring-boot#sistemas-distribuídos
Uma conexão recusada no meio de um deploy, um 503 de uma instância que acabou de reiniciar: muita falha dura um instante e some na tentativa seguinte. É para isso que serve o retry. Em vez de devolver o erro para quem chamou, a aplicação espera um pouco e tenta de novo.
O custo é tempo e carga extra justamente no serviço que está com problema, e é por aí que o retry começa a atrapalhar. Vou colocá-lo no exemplo da parte 1 e depois mostrar quatro jeitos de ele piorar a falha que deveria esconder.
O código
O cliente de pagamento ganha um @Retry por cima do @CircuitBreaker. Repare que o fallback mudou de lugar: saiu do circuit breaker e foi para o retry. O motivo é a primeira piora.
@Retry(name = "pagamento", fallbackMethod = "statusIndisponivel")
@CircuitBreaker(name = "pagamento")
public String status(String pedidoId) {
return http.get()
.uri("/simulador/pagamento/{id}", pedidoId)
.retrieve()
.body(String.class);
}Os fallbacks continuam os mesmos da parte 1. A configuração do retry:
max-attempts conta a chamada original. Com 3, são duas repetições.
A primeira espera é de 200 ms, e cada uma das seguintes dobra.
Cada espera é sorteada em até 50% para mais ou para menos. O motivo fica para a última piora.
O retry não repete um circuito aberto nem um erro 4xx. Sem essa lista, ele repete qualquer exceção.
resilience4j:
retry:
instances:
pagamento:
# conta a chamada original: são 2 repetições
max-attempts: 3
# espera 200ms, depois 400ms...
wait-duration: 200ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
# ...e sorteia cada espera em até 50% para mais ou para menos
enable-randomized-wait: true
randomized-wait-factor: 0.5
# não repete circuito aberto nem erro de quem chamou (4xx)
ignore-exceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
- org.springframework.web.client.HttpClientErrorExceptionCom o serviço falhando só nas duas primeiras chamadas, o pedido passa na terceira:
$
Quem chamou não viu as duas falhas. O serviço, sim: recebeu 3 chamadas para 1 pedido.
Piora 1: o retry que nunca acontece
Na ordem padrão dos aspectos do Resilience4j, o retry fica por fora: Retry ( CircuitBreaker ( ... ) ). E o fallback funciona como um try/catch em volta do aspecto em que está. Com o fallback no @CircuitBreaker, o erro é tratado ali dentro e o retry recebe uma resposta normal.
@Retry(name = "pagamento")
@CircuitBreaker(name = "pagamento",
fallbackMethod = "statusIndisponivel")
public String status(String pedidoId) { ... }@Retry(name = "pagamento",
fallbackMethod = "statusIndisponivel")
@CircuitBreaker(name = "pagamento")
public String status(String pedidoId) { ... }Com o mesmo serviço falhando só nas duas primeiras chamadas, o pedido que passaria na terceira tentativa volta como falha:
$
Uma chamada e nenhum evento de retry. A configuração está lá, mas o retry nunca roda.
Piora 2: repetir o circuito aberto
Sem configuração, o retry repete qualquer exceção, inclusive a CallNotPermittedException do circuito aberto. Com o serviço fora do ar, rodei quatro pedidos com e sem o ignore-exceptions:
No pedido 1 o circuito ainda estava fechado, e as tentativas chegaram ao serviço nas duas versões. Depois que ele abre, a diferença aparece. Com ignore-exceptions, a rejeição volta em 2 ms. Sem ele, os pedidos 3 e 4 levaram 0,39 s e 0,50 s sem que nenhuma chamada chegasse ao serviço: cada repetição esperou o backoff para receber a mesma rejeição. O fail fast da parte 1 deixa de existir.
Piora 3: tentativas que se multiplicam
O retry do exemplo está numa camada só. Se cada camada do sistema tiver o seu, as tentativas se multiplicam. O livro de SRE do Google descreve o total como o produto das tentativas de cada camada. Com max-attempts: 3 em três camadas, um clique vira 27 chamadas no serviço lá de baixo, que é justamente o que está fora do ar.
3 × 3 × 3 = 27 chamadas no adquirente para 1 clique
Com o retry ligado em uma camada só, o total volta para 3.
Piora 4: todo mundo tenta ao mesmo tempo
Quando o serviço cai, todos os clientes falham juntos. Com intervalo fixo, repetem juntos também. O backoff exponencial espaça as tentativas, mas, sem aleatoriedade, elas continuam chegando em ondas. Marc Brooker mostra esses grupos de chamadas no blog de arquitetura da AWS. Por isso o exemplo liga o enable-randomized-wait: cada cliente sorteia a própria espera e a carga se espalha.
Antes de ligar o retry
Retry só é seguro em operação idempotente. Repetir uma cobrança que deu timeout pode cobrar duas vezes, porque o timeout não diz se o serviço chegou a processar o pedido. O livro de SRE também sugere duas proteções que o exemplo não tem: um limite de repetições por processo e um status específico de "sobrecarregado", para quem chama saber que não deve repetir.
Quando o serviço responde 503 ou 429 com o cabeçalho Retry-After, ele já diz quanto esperar, em segundos ou com uma data. O intervalBiFunction do Resilience4j recebe a exceção de cada tentativa e pode usar esse valor no lugar do backoff.
teste rápido
Com max-attempts: 3, quantas chamadas o serviço recebe, no máximo, para um pedido?
teste rápido
Um método tem @Retry e @CircuitBreaker. Onde o fallback precisa ficar para o retry funcionar?
teste rápido
Por que colocar a CallNotPermittedException no ignore-exceptions do retry?