brunodiedri.ch
blog

Circuit breaker com Resilience4j: o código primeiro

Um exemplo mínimo em Spring Boot 3 e o que cada número da configuração faz com o tráfego.

·7 min de leitura#resiliência#java#spring-boot#sistemas-distribuídos

Quando um serviço cai de vez, quem chama recebe um retorno de erro rápido e segue em frente. O pior caso é quando o serviço fica lento. Cada chamada feita segura uma thread esperando resposta, as threads acabam e quem executava a chamada é derrubado junto.

serviço lento
ClientePedidosPagamento1. GET /pedidos/1/pagamento2. consulta (sem resposta)3. GET /pedidos/2/pagamento4. consulta (sem resposta)5. GET /pedidos/3/pagamento6. timeout
passo 1/6
GET /pedidos/1/pagamento

Um circuit breaker para de chamar o serviço por um tempo quando ele começa a falhar ou demorar. Vou montar um com Resilience4j e Spring Boot 3 primeiro e explicar a teoria depois.

O código

Além do starter web, são três dependências: resilience4j-spring-boot3, spring-boot-starter-aop (a anotação funciona como um aspecto) e spring-boot-starter-actuator (para ver o estado do circuito). A configuração fica toda no application.yml:

1

A janela guarda o resultado das últimas 10 chamadas. Com menos de 5 chamadas nela, o circuito ainda não calcula nada.

2

O circuito abre quando metade das chamadas da janela falhou, ou quando metade demorou mais de 2 segundos.

3

Aberto, ele rejeita tudo por 4 segundos. Depois deixa passar 3 chamadas de teste.

4

Um erro 4xx não conta como falha: o serviço respondeu, quem errou foi a requisição.

5

O estado do circuito passa a aparecer no /actuator/health.

circuit-breaker/application.yml
resilience4j:
  circuitbreaker:
    instances:
      pagamento:
        # janela: as últimas 10 chamadas
        sliding-window-type: COUNT_BASED
        sliding-window-size: 10
        minimum-number-of-calls: 5
        # abre com 50% de falha...
        failure-rate-threshold: 50
        # ...ou com 50% das chamadas acima de 2s
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 50
        # quanto tempo fica aberto e quantas chamadas testam a volta
        wait-duration-in-open-state: 4s
        permitted-number-of-calls-in-half-open-state: 3
        # erro 4xx é problema de quem chamou, não do serviço
        ignore-exceptions:
          - org.springframework.web.client.HttpClientErrorException
        register-health-indicator: true

management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers,circuitbreakerevents
  endpoint:
    health:
      show-details: always
  health:
    circuitbreakers:
      enabled: true

server:
  port: 9090

E o cliente que chama o serviço de pagamento:

PagamentoClient.java
@CircuitBreaker(name = "pagamento", fallbackMethod = "statusIndisponivel")
public String status(String pedidoId) {
    return http.get()
            .uri("/simulador/pagamento/{id}", pedidoId)
            .retrieve()
            .body(String.class);
}
 
private String statusIndisponivel(String pedidoId, CallNotPermittedException e) {
    return "pedido " + pedidoId + ": pagamento indisponível, tente mais tarde";
}
 
private String statusIndisponivel(String pedidoId, Exception e) {
    return "pedido " + pedidoId + ": falha ao consultar o pagamento";
}

O name liga o método à configuração pagamento do YAML. O fallback precisa estar na mesma classe, com os mesmos parâmetros mais uma exceção. Com dois fallbacks, o Resilience4j escolhe o da exceção mais específica.

Uma pegadinha do Spring: a anotação só vale para chamadas vindas de outro bean. Se a própria PagamentoClient chamar status(), a chamada não passa pelo proxy e o circuito não vê nada.

A teoria, com os números do código

O circuito tem três estados. Em CLOSED, as chamadas passam e cada resultado entra na janela. Quando a taxa de falha passa do limite, ele vai para OPEN e rejeita as chamadas sem nem tentar: o Resilience4j lança CallNotPermittedException, que é o caso do primeiro fallback. Depois do tempo de espera vem o HALF_OPEN, que libera algumas chamadas de teste e decide se fecha ou abre de novo.

A simulação abaixo usa os mesmos números do YAML. Suba a falha do serviço para 60% e acompanhe a janela.

CircuitBreaker.java — simulação
cliente
breaker
serviço
20% falha
CLOSEDHALF_OPENOPEN
janela 0/10 · 0 falhas
0 0 0 rejeitadas
  • Suba a falha do serviço para ver o circuito abrir.
Janela das últimas 10 chamadas. Com pelo menos 5 chamadas e falha ≥ 50%, o circuito abre e passa a rejeitar na hora (fail fast). Depois de 4s ele deixa passar 3 chamadas de teste: se as taxas delas ficarem abaixo dos limites, fecha de novo.

Chamadas lentas também contam

Com slow-call-duration-threshold e slow-call-rate-threshold, uma chamada que passa de 2 segundos entra na conta mesmo quando dá certo. Na simulação abaixo, deixe a falha em 0% e suba a latência para uns 3.000 ms: o circuito abre sem nenhum erro, que é o cenário do começo do post.

CircuitBreaker.java — simulação
cliente
breaker
serviço
0% falha
~800ms
CLOSEDHALF_OPENOPEN
janela 0/10 · 0 falhas · 0 lentas
0 0 0 lentas 0 rejeitadas0 esperando resposta
  • Deixe a falha em 0% e suba a latência para ver o circuito abrir sem nenhum erro.
Janela das últimas 10 chamadas. Com pelo menos 5 chamadas e falha ≥ 50% ou chamadas acima de 2s ≥ 50%, o circuito abre e passa a rejeitar na hora (fail fast). Depois de 4s ele deixa passar 3 chamadas de teste: se as taxas delas ficarem abaixo dos limites, fecha de novo.

Repare que as bolinhas lentas continuam esperando no serviço. O circuit breaker não interrompe uma chamada em andamento, ele só conta o tempo quando ela termina. Quem corta a espera é um timeout no cliente HTTP ou, em métodos assíncronos, o TimeLimiter do próprio Resilience4j.

O que costuma confundir

Os valores padrão. Sem configuração, a janela e o mínimo de chamadas são 100, o circuito fica 60 segundos aberto, uma chamada só é lenta depois de 60 segundos e o limite de lentidão é 100%. Um endpoint com pouco tráfego pode nunca abrir o circuito, e lentidão quase nunca conta.

A volta para HALF_OPEN. Por padrão (automatic-transition-from-open-to-half-open-enabled: false), ela não acontece sozinha: o circuito só muda de estado na primeira chamada depois dos 4 segundos. Na simulação a troca é automática porque sempre tem tráfego chegando.

O teste em HALF_OPEN. As 3 chamadas de teste são avaliadas juntas, com os mesmos limites, e chamadas extras recebem CallNotPermittedException até elas terminarem.

Um circuito por instância. Cada instância da aplicação tem o próprio circuito. Com três réplicas são três janelas independentes, e uma pode estar aberta enquanto as outras seguem fechadas.

Vendo o circuito rodar

actuator
$

As cinco primeiras chamadas falharam e, com o mínimo de 5 atingido e 100% de falha, o circuito abriu. A sexta nem saiu da aplicação: caiu no fallback de CallNotPermittedException (notPermittedCalls: 1 e o evento NOT_PERMITTED). Repare também que o circuito aberto aparece como CIRCUIT_OPEN, e não como DOWN: com essa configuração, o health da aplicação continua UP.

Rodando o roteiro de novo, com os 4 segundos já vencidos, a primeira chamada leva o circuito para HALF_OPEN. As 3 de teste falham, ele volta para OPEN e rejeita as outras:

actuator — segunda rodada
$

teste rápido

Sem nenhuma configuração, um endpoint recebe 30 chamadas por minuto e todas falham. O circuito abre no primeiro minuto?

E o retry?

Deixei o retry de fora de propósito, porque ele mexe em quase tudo que configurei aqui. Na ordem padrão dos aspectos, o @Retry fica por fora do @CircuitBreaker, então cada nova tentativa passa pelo circuito e conta na janela. E, sem configuração, ele repete qualquer exceção, inclusive a CallNotPermittedException de um circuito aberto.

Na parte 2, explico por que e para que serve o retry e mostro, neste mesmo exemplo, como ele pode atrapalhar tudo: em vez de esconder a falha, ele a deixa maior.