brunodiedri.ch
blog

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.

PagamentoClient.java
@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:

1

max-attempts conta a chamada original. Com 3, são duas repetições.

2

A primeira espera é de 200 ms, e cada uma das seguintes dobra.

3

Cada espera é sorteada em até 50% para mais ou para menos. O motivo fica para a última piora.

4

O retry não repete um circuito aberto nem um erro 4xx. Sem essa lista, ele repete qualquer exceção.

retry/application.yml
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.HttpClientErrorException

Com o serviço falhando só nas duas primeiras chamadas, o pedido passa na terceira:

retry que funciona
$

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.

fallback no circuit breaker
@Retry(name = "pagamento")
@CircuitBreaker(name = "pagamento",
                fallbackMethod = "statusIndisponivel")
public String status(String pedidoId) { ... }
fallback no retry
@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:

fallback no lugar errado
$

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:

circuito aberto, com e sem 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.

retry em camadas
1 clique do usuário
bff
0de 1
pedidos
0de 3
pagamento
0de 9
adquirentefora do ar
0de 27

3 × 3 × 3 = 27 chamadas no adquirente para 1 clique

O adquirente está fora do ar. Cada camada só devolve o erro para cima depois de esgotar as próprias tentativas, então o número de chamadas que chega embaixo é o produto das tentativas de cada camada. Desligue o retry em algumas camadas e compare.

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.

retry com backoff — thundering herd
↓ serviço fora do arcapacidade0s2s4s6s8s10s12s14s16s
pico 40 req/250mstentativas 390atendidos 20/4020 desistiram após 10 tentativas sucesso falha
estratégia
O serviço cai em t = 0 e volta em t = 2s, aguentando 10 requisições a cada 250ms (linha tracejada). Sem jitter, todos os clientes tentam de novo no mesmo instante e derrubam o serviço de novo. Com jitter, as tentativas se espalham.

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?