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.
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:
A janela guarda o resultado das últimas 10 chamadas. Com menos de 5 chamadas nela, o circuito ainda não calcula nada.
O circuito abre quando metade das chamadas da janela falhou, ou quando metade demorou mais de 2 segundos.
Aberto, ele rejeita tudo por 4 segundos. Depois deixa passar 3 chamadas de teste.
Um erro 4xx não conta como falha: o serviço respondeu, quem errou foi a requisição.
O estado do circuito passa a aparecer no /actuator/health.
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: 9090E o cliente que chama o serviço de pagamento:
@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.
20% falha
- Suba a falha do serviço para ver o circuito abrir.
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.
0% falha
~800ms
- Deixe a falha em 0% e suba a latência para ver o circuito abrir sem nenhum erro.
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
$
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:
$
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.