Pular para o conteúdo

Circuit Breaker

Quando um serviço downstream fica lento ou indisponível, os chamadores continuam enviando requisições que penduram em timeouts. Cada chamada presa consome uma thread/conexão do pool do chamador. Se o pool esgota, o chamador também fica indisponível — e quem chama o chamador cai também. É a cascata de falhas (resource exhaustion): uma falha localizada se propaga e derruba a árvore de serviços acima.

O disjuntor quebra essa propagação: em vez de esperar timeouts contra uma dependência morta, ele falha rápido, liberando recursos e contendo o incidente.

  • Closed (fechado): estado normal. As chamadas passam. O breaker conta falhas numa janela (ex.: sliding window de N chamadas ou de tempo). Se a taxa de falha ultrapassa o limiar → transita para open.
  • Open (aberto): as chamadas são rejeitadas imediatamente (ex.: CallNotPermittedException), sem tocar a dependência. Aciona-se o Fallback. Após um waitDuration → transita para half-open.
  • Half-open (semiaberto): permite um número limitado de chamadas de sondagem. Se tiverem sucesso (dependência recuperada) → volta a closed. Se falharem → volta a open e reinicia a espera.
stateDiagram-v2
    [*] --> Closed
    Closed --> Open: taxa de falha > limiar
    Open --> HalfOpen: após waitDuration
    HalfOpen --> Closed: sondagens com sucesso
    HalfOpen --> Open: sondagem falhou
    note right of Open
        rejeita chamadas (fail-fast)
        aciona Fallback
    end note
    note right of HalfOpen
        permite N chamadas de teste
    end note
  • failureRateThreshold: % de falhas na janela que abre o circuito (ex.: 50%).
  • slidingWindow (count-based ou time-based): quantas chamadas/segundos são avaliados.
  • minimumNumberOfCalls: mínimo de amostras antes de calcular a taxa (evita abrir por 1 falha em 2 chamadas).
  • waitDurationInOpenState: tempo em open antes de sondar.
  • permittedNumberOfCallsInHalfOpenState: quantas sondagens em half-open.
  • slowCallRateThreshold / slowCallDurationThreshold: trata chamadas lentas (não só erros) como falha — essencial, pois lentidão é o que esgota pools.
  • Contagem por erros vs por lentidão: além de exceções, contar chamadas que excedem um limite de duração como falha.
  • Bulkhead (compartimentação): isola pools de recursos por dependência para que uma não afete as outras — complementar ao breaker.
  • Rate limiter: relacionado, mas protege por taxa, não por falha.
  • Prós: contém cascata, libera recursos, dá tempo de recuperação à dependência, melhora estabilidade global.
  • Contras: pode rejeitar chamadas que teriam sucesso (falsos positivos se mal calibrado); exige tuning cuidadoso dos limiares; adiciona um estado a observar e monitorar.
  • Em chamadas locais/in-process que não fazem I/O de rede — não há cascata a conter.
  • Para dependências sem alternativa e sem custo de cascata onde falhar rápido não ajuda (raro).
  • Quando a dependência tem falhas legítimas frequentes que não indicam indisponibilidade — o breaker abriria à toa; ajuste o limiar em vez de aplicar cegamente.

Circuit breaker + Retry + Fallback na mesma chamada. A ordem de composição importa: retry por fora do breaker, fallback cobrindo tudo.

@Configuration
public class ResilienceConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
var config = CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(20)
.minimumNumberOfCalls(10)
.failureRateThreshold(50f)
.slowCallRateThreshold(80f)
.slowCallDurationThreshold(Duration.ofSeconds(2))
.waitDurationInOpenState(Duration.ofSeconds(10))
.permittedNumberOfCallsInHalfOpenState(3)
.build();
return CircuitBreakerRegistry.of(config);
}
}
@Service
@RequiredArgsConstructor
public class InventoryGateway {
private final RestClient restClient;
@Retry(name = "inventory", fallbackMethod = "unknownStock")
@CircuitBreaker(name = "inventory")
public StockLevel checkStock(String sku) {
return restClient.get()
.uri("/stock/{sku}", sku)
.retrieve()
.body(StockLevel.class);
}
private StockLevel unknownStock(String sku, Throwable ex) {
if (ex instanceof CallNotPermittedException) {
log.warn("Circuito ABERTO para inventory, sku={}", sku);
}
return StockLevel.unknown(sku);
}
}

Com Spring AOP/Resilience4j, a ordem default é @Retry por fora de @CircuitBreaker: cada tentativa do retry conta como uma chamada na janela do breaker. Por isso o fallbackMethod fica no @Retry, o aspecto mais externo — cobre tanto CallNotPermittedException (circuito aberto) quanto o esgotamento das tentativas. Se ficasse no @CircuitBreaker (interno), o fallback devolveria um valor normal na primeira falha e o retry externo nunca dispararia.

  • Limiar mal calibrado: minimumNumberOfCalls baixo abre o circuito por ruído estatístico; threshold alto demais nunca protege.
  • Ignorar chamadas lentas: contar só exceções e não lentidão — mas é a lentidão que esgota os pools. Configure slowCallRateThreshold.
  • Um breaker global para várias dependências: uma dependência ruim abre o circuito das saudáveis. Use um breaker por dependência (idealmente com bulkhead).
  • Sem Fallback: circuito aberto lançando exceção crua ao usuário — perde-se metade do valor do padrão.
  • waitDurationInOpenState curto demais: volta a sondar antes de a dependência se recuperar, oscilando entre open e half-open.
  • Retry agressivo sem breaker: os retries martelam a dependência morta; o breaker precisa cortá-los.
  1. Descreva os três estados do circuit breaker e suas transições.
Resposta

Closed: chamadas passam e falhas são contadas; se a taxa de falha excede o limiar, vai para open. Open: chamadas são rejeitadas imediatamente (fail-fast) sem tocar a dependência; após waitDuration, vai para half-open. Half-open: permite um número limitado de sondagens; se têm sucesso, volta a closed; se falham, volta a open e reinicia a espera.

  1. Que problema o circuit breaker previne e como?
Resposta

Previne a cascata de falhas por esgotamento de recursos: chamadas presas em timeout contra uma dependência lenta/morta consomem threads e conexões do chamador até esgotá-las, derrubando-o e propagando a falha para cima. Ao abrir o circuito e falhar rápido, o breaker libera esses recursos e contém a propagação.

  1. Por que contar chamadas lentas (não só erros) é importante?
Resposta

Porque a lentidão, mais que os erros, é o que esgota os pools: chamadas que demoram (mas não falham) seguram threads/conexões. Um breaker que só conta exceções não abriria diante de uma dependência degradada porém “respondendo”, e a cascata aconteceria mesmo assim. Por isso configura-se slowCallRateThreshold e slowCallDurationThreshold.

  1. Por que usar um circuit breaker por dependência em vez de um global?
Resposta

Porque um breaker global mistura os sinais de todas as dependências: falhas de um serviço ruim abririam o circuito, bloqueando também as chamadas a serviços saudáveis. Um breaker por dependência (idealmente com bulkhead isolando os pools) garante que a proteção seja localizada e não penalize dependências que estão funcionando.