Um provedor de pagamentos passa a responder em dez segundos em vez de cinquenta milissegundos. O seu serviço de checkout continua chamando, cada requisição prende um worker enquanto espera e, em um minuto, o checkout fica sem workers livres, inclusive para requisições que nem precisavam do provedor de pagamentos. Depois, os serviços que chamam o checkout também começam a estourar o timeout. Uma única dependência lenta virou uma queda que atinge vários times.
Um circuit breaker interrompe essa cadeia recusando-se, por um tempo, a chamar uma dependência que está claramente falhando e respondendo na hora no lugar dela. Este post cobre os três estados com um diagrama de estados, os limiares que decidem quando o breaker dispara, como ele se combina com timeouts, retries e bulkheads, o que faz um bom fallback e onde colocar o breaker: no seu código ou em um service mesh.
Por que as falhas se propagam em cascata
A conta é simples e implacável. Um serviço com 200 threads de trabalho chamando uma dependência que responde em 50 ms consegue atender cerca de 4.000 requisições por segundo por esse caminho. Se a dependência desacelera até um timeout de 10 segundos, as mesmas 200 threads atendem 20 requisições por segundo. Nada está quebrado no seu serviço; a capacidade dele simplesmente foi consumida pela espera.
Os retries pioram tudo. Se três camadas de serviços fazem três retries cada uma, uma única requisição de usuário pode virar 27 chamadas à dependência em apuros lá embaixo, justamente no momento em que ela menos aguenta.
Michael Nygard descreveu o circuit breaker em 2007, no livro dele sobre como construir software para produção, e a descrição de Martin Fowler fez dele uma peça padrão do design de serviços: encapsular as chamadas a uma dependência, contar as falhas e, quando elas passam de um limiar, parar de chamar por um tempo.
Os três estados
| Estado | O que acontece com as chamadas | Passa para |
|---|---|---|
| Fechado (Closed) | As chamadas passam; os resultados são registrados em uma janela deslizante | Aberto, quando a taxa de falhas ou de chamadas lentas passa do limiar |
| Aberto (Open) | As chamadas falham na hora sem tocar a dependência; o fallback responde | Semiaberto, depois de um tempo de espera |
| Semiaberto (Half-open) | Um pequeno número de chamadas de teste passa; o resto continua falhando na hora | Fechado se os testes dão certo, aberto se falham |
O estado aberto faz dois trabalhos ao mesmo tempo. Ele protege o seu serviço, porque falhar rápido libera os workers na hora. E protege a dependência, que ganha uma pausa no tráfego enquanto se recupera, em vez de uma pilha crescente de retries.
O estado semiaberto existe porque o breaker não tem como saber se a dependência voltou a ficar saudável sem tentar. Deixar passar um punhado de chamadas, e não todo o tráfego, testa a recuperação sem derrubar a dependência no exato momento em que ela volta.
O que conta como falha
Decida isso de propósito, porque a escolha errada abre breakers pelos motivos errados:
- Conte: timeouts, erros de conexão, respostas 5xx e chamadas mais lentas que o seu limiar de chamada lenta.
- Não conte: respostas 4xx como 400 ou 404. Elas descrevem uma requisição errada, não uma dependência falhando, e um único cliente com mau comportamento não deveria cortar todo mundo.
- Decida caso a caso: 429 Too Many Requests. Isso significa que a dependência está se protegendo, o que muitas vezes é um bom motivo para recuar.
Chamadas lentas importam tanto quanto erros. Uma dependência que responde com sucesso a toda requisição em oito segundos é, para uma API interativa, uma dependência que está falhando.
Escolher os limiares
O Resilience4j, uma biblioteca de breaker muito usada na JVM, vem com padrões conservadores que vale a pena conhecer, porque muitos times nunca os mudam:
| Configuração | Padrão | O que significa |
|---|---|---|
failureRateThreshold |
50% | Abrir quando metade das chamadas registradas falhou |
slidingWindowType / slidingWindowSize |
Por contagem, 100 | Avaliar as últimas 100 chamadas |
minimumNumberOfCalls |
100 | Não avaliar antes de 100 chamadas registradas |
slowCallDurationThreshold |
60.000 ms | Chamadas mais lentas que isso contam como lentas |
slowCallRateThreshold |
100% | Abrir só se todas as chamadas forem lentas |
waitDurationInOpenState |
60.000 ms | Ficar aberto por um minuto |
permittedNumberOfCallsInHalfOpenState |
10 | Dez chamadas de teste decidem a recuperação |
Para uma API interativa, os padrões de chamada lenta estão, na prática, desligados: ninguém espera 60 segundos por uma resposta. Pontos de partida razoáveis são um limiar de chamada lenta perto do seu objetivo de latência, uma taxa de chamadas lentas de 50% a 80% e uma espera no estado aberto de 10 a 30 segundos. Mantenha o minimumNumberOfCalls significativo: em um endpoint com pouco tráfego, duas falhas em três chamadas são ruído, não uma queda. Janelas por tempo se comportam melhor que janelas por contagem quando o tráfego varia muito ao longo do dia.
Timeouts, retries e bulkheads
Um circuit breaker é uma de quatro ferramentas que só funcionam bem juntas:
- Timeouts vêm primeiro. Sem timeout, uma chamada lenta nunca falha, então o breaker não vê nada para contar. Defina cada timeout abaixo do timeout de quem chama e passe os prazos adiante pela cadeia de chamadas, para que ninguém continue trabalhando em uma requisição que já foi abandonada.
- Retries precisam de limites. Faça retry só de operações idempotentes, use backoff exponencial com jitter como descrito no AWS Architecture Blog, faça retry em uma camada só, e não em todas, e limite os retries a uma fração do tráfego. Os orçamentos de retry do Envoy são, por padrão, 20% das requisições ativas quando você os ativa.
- Nunca faça retry contra um breaker aberto. O Resilience4j aplica o retry por fora do breaker por padrão, então cada tentativa passa por ele; configure os retries para parar no erro “call not permitted”, em vez de martelar um breaker que já decidiu.
- Bulkheads isolam. Dê a cada dependência o próprio pool de conexões ou limite de concorrência, para que um provedor de pagamentos lento só consiga consumir o pool dele, e não todos os workers do serviço.
Fallbacks que fazem sentido
Quando o breaker está aberto, alguma coisa precisa responder. O fallback certo depende de para que servem os dados:
| Funcionalidade | Bom fallback | Mau fallback |
|---|---|---|
| Recomendações | Itens populares, ou esconder a seção | Fazer retry no serviço de recomendações em loop |
| Avatar do usuário | Uma imagem genérica | Bloquear a página até o serviço de imagens responder |
| Estimativa de frete | Uma estimativa em cache com um aviso | Uma data de entrega inventada |
| Preço ou pagamento | Um erro claro e um jeito de tentar de novo | Um preço chutado ou uma cobrança pulada em silêncio |
Um fallback precisa ser barato e não pode depender daquilo que acabou de falhar. Se a resposta honesta é “isso não está funcionando agora”, falhe rápido com um erro claro; isso ainda é muito melhor do que um timeout.
Onde colocar o breaker
| Biblioteca no processo | Service mesh ou proxy | |
|---|---|---|
| Exemplos | Resilience4j (Java), Polly (.NET), gobreaker (Go), opossum (Node.js) | Circuit breaking e outlier detection do Envoy |
| Enxerga | Exceções, falhas de negócio, fallbacks | Conexões, requisições, códigos de resposta |
| Fallbacks | Sim, no seu código | Não; quem chama recebe um erro |
| Consistência entre times | Cada time configura o seu | Uma política só, aplicada em todo lugar |
Vale a pena entender o modelo do Envoy, porque ele é diferente do modelo dos livros. Os circuit breakers dele são limites de concorrência por cluster upstream: por padrão, 1.024 conexões, 1.024 requisições pendentes, 1.024 requisições ativas e 3 retries simultâneos. A detecção de falhas por host é um recurso separado, o outlier detection, que por padrão ejeta um host depois de cinco respostas 5xx seguidas por 30 segundos, por mais tempo em ejeções repetidas, e nunca ejeta mais de 10% de um cluster. O diagrama de arquitetura de service mesh mostra onde esses proxies ficam.
Muitos times usam os dois: o mesh para limites consistentes e para ejetar hosts ruins, e uma biblioteca onde a aplicação precisa de um fallback de verdade. O Hystrix, da Netflix, que popularizou o padrão, está em modo de manutenção desde 2018; o Resilience4j é o substituto usual na JVM.
Observe e teste
Um breaker que abre em silêncio transforma uma queda em um mistério. Emita cada mudança de estado como evento de log e como métrica, gere um alerta quando um breaker ficar aberto e mostre o estado dos breakers no mesmo dashboard da latência da dependência. Depois teste: injete latência e erros na dependência em um ambiente de staging e veja o breaker abrir, o fallback responder e o breaker fechar de novo. O diagrama de arquitetura de observabilidade cobre o lado de métricas e tracing.
O diagrama do circuit breaker
Conteúdo do diagrama
- CLOSED
- OPEN
- HALF-OPEN
- Checkout service (caller)
- Payment provider
- Fallback: clear error, retry later
- failure rate ≥ 50% of the last 100 calls
- after a 60 s wait
- a trial call fails
- trial calls succeed
- charge(), 2 s timeout, when closed or half-open
- when open: no call, fail fast
Como ler:
- Três estados. Fechado é o estado normal, aberto falha rápido e semiaberto testa a recuperação. O ponto marca o estado inicial.
- As transições levam as suas condições: uma taxa de falhas de 50% nas últimas 100 chamadas abre o breaker, uma espera de 60 segundos o leva para semiaberto, e o resultado das chamadas de teste o fecha ou o abre de novo.
- O caminho de chamadas embaixo mostra quem chama, o provedor de pagamentos atrás do breaker e o fallback que responde enquanto o breaker está aberto.
- O bloco Markdown lista as configurações com os valores padrão.
Desenhar padrões de resiliência com clareza
- Desenhe o breaker como uma máquina de estados. Descrições em texto corrido borram a diferença entre aberto e semiaberto; um diagrama deixa isso óbvio.
- Escreva os números nas transições. “Abre com falhas” convida a discussão; “abre com 50% das últimas 100 chamadas” convida a revisão.
- Mostre o caminho do fallback, inclusive o que o usuário vê.
- Coloque timeouts e retries no diagrama do caminho de chamadas, para que os revisores consigam multiplicar os retries entre as camadas.
Para saber mais sobre a notação de máquinas de estados, veja o exemplo de diagrama de estados. O diagrama de arquitetura de microsserviços mostra onde os breakers ficam entre os serviços.
Monte no Archboard
- Entre com Google ou GitHub e crie um diagrama.
- Adicione três nós Estado do pack UML: Closed, Open e Half-open. Pinte-os de verde, vermelho e âmbar.
- Adicione uma pequena forma Elipse do pack Básico como estado inicial e ligue-a a Closed.
- Desenhe as transições como arestas rotuladas: o limiar de falhas, o tempo de espera, o sucesso e a falha dos testes. Adicione ao lado de cada estado um Texto do pack Básico dizendo o que acontece com as chamadas ali.
- Abaixo da máquina de estados, adicione uma Caixa do pack Básico para quem chama, um Sistema externo do pack Modelo C4 para o provedor de pagamentos e outra Caixa para o fallback, com uma aresta tracejada para o caminho do estado aberto.
- Adicione um bloco Markdown do pack Básico com as configurações e os valores padrão.
O exemplo usa 8 nós de diagrama e fica dentro do plano Free.
Perguntas frequentes
Quais são os três estados de um circuit breaker? Fechado, em que as chamadas passam e as falhas são contadas; aberto, em que as chamadas falham na hora durante um tempo de espera; e semiaberto, em que algumas chamadas de teste decidem se o breaker fecha de novo ou volta a abrir.
Qual é a diferença entre um circuit breaker e um retry? Um retry repete uma chamada que falhou na esperança de que ela dê certo. Um circuit breaker para totalmente as chamadas quando as falhas são frequentes. Usados juntos, os retries devem ser limitados e parar quando o breaker está aberto.
Erros 4xx devem abrir um circuit breaker? Normalmente não. Um 400 ou 404 significa que a requisição estava errada, não que a dependência falhou. Conte em vez disso timeouts, erros de conexão, respostas 5xx e chamadas lentas.
Preciso de um circuit breaker se uso um service mesh? Um mesh oferece limites de concorrência e a ejeção de hosts ruins sem mudar o código. Você ainda precisa de um breaker no processo onde quiser um fallback de verdade, como um valor em cache ou uma funcionalidade degradada.
Qual é um bom tempo de espera para o estado aberto? Longo o bastante para uma dependência em apuros se recuperar e curto o bastante para que os usuários não vejam uma queda longa depois que ela se recupera. De dez a trinta segundos é um ponto de partida comum para APIs interativas.