Circuit breaker em MuleSoft: quando o retry piora as coisas
Código deste artigo:
mule4-circuit-breaker(plugin, MIT) · demonstração:mule4-circuit-breaker-demo-app(aplicação Mule real, MIT)
Sexta-feira de saldão. O checkout da sua loja online chama o serviço de pagamento pra autorizar o cartão do cliente. Por volta das 14h esse serviço começa a demorar mais que o normal — nada dramático, só alguns segundos a mais por chamada. O checkout tem retry configurado, porque ninguém quer perder uma venda por causa de um timeout isolado. O problema é que, com o tráfego de sexta-feira, cada timeout agora significa três tentativas em vez de uma. O serviço de pagamento, que já estava no limite, passa a receber o triplo do tráfego que já não conseguia atender — e é exatamente aí que ele para de responder de vez. Em minutos, todo carrinho abandonado na loja tem o mesmo motivo: o checkout, não o cliente.
Qualquer varejista, distribuidora ou marketplace reconhece esse cenário. Um backend externo degrada sob carga, e o retry, pensado pra proteger o cliente, vira o empurrão final que derruba o backend de vez. É esse problema que um circuit breaker resolve: depois de um número configurável de falhas, ele para de bater no serviço que já está caindo, devolve erro na hora pros pedidos seguintes, e testa a recuperação sozinho — sem alguém reiniciando nada às 15h de uma sexta-feira de saldão.
Dá para medir a diferença entre “só retry” e “retry com circuit breaker” sem depender de opinião. RetryVsCircuitBreakerDemoTest, no repositório do plugin, simula 20 requisições chegando enquanto um backend responde HTTP:TIMEOUT em toda chamada, dos dois jeitos:
- Só retry: cada requisição chama o backend direto, com até 3 tentativas — as 20 esgotam as tentativas contra o backend ainda quebrado. Total: 60 chamadas ao backend, ~1929 ms, 0 sucessos.
- Retry + circuit breaker: cada chamada passa por
circuit-breaker:execute. As primeiras 5 requisições alimentam a janela deslizante e abrem o circuito; as 15 seguintes recebemCIRCUIT-BREAKER:OPENna hora, sem o backend ser chamado de novo. Total: 5 chamadas ao backend, ~139 ms.
O teste não depende de olhar log e achar que ficou mais rápido — ele afirma mecanicamente que o número de chamadas ao backend e o tempo total do cenário com circuit breaker são estritamente menores que os do cenário só-retry. Reproduza com:
mvn test -Dtest=RetryVsCircuitBreakerDemoTest
O padrão: três estados
Circuit breaker não é invenção de integração — Martin Fowler descreve o padrão e o Azure Architecture Center tem a mesma forma, com três estados:
- CLOSED: chamadas passam normalmente; o circuito observa os resultados.
- OPEN: chamadas são bloqueadas na hora, sem tocar o backend, depois que a taxa de falha ultrapassa um limite.
- HALF_OPEN: depois de um tempo de espera, um número limitado de chamadas de teste decide se o circuito fecha de novo ou volta a abrir.
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: taxa de falha na janela > limite (com mínimo de chamadas)
OPEN --> HALF_OPEN: tempo de espera (resetTimeout) expira
HALF_OPEN --> CLOSED: chamadas de teste com sucesso suficiente
HALF_OPEN --> OPEN: qualquer falha durante o teste
Duas camadas possíveis no MuleSoft
Antes de plugar algo no flow do checkout, vale saber que o MuleSoft já publica uma alternativa sem código: a Custom Policy oficial de circuit breaker, aplicada no API Manager. Ela atua na camada de gateway — protege uma API contra o backend que ela expõe, configurada pela UI, sem alterar o flow. É a escolha certa quando você não é dono do código do flow, ou quer aplicar o mesmo circuito a várias APIs de forma centralizada.
O plugin deste artigo atua numa camada diferente: dentro do flow, envolvendo um processador específico (tipicamente um http:request) que pode estar no meio de uma lógica de negócio maior — como a chamada ao serviço de pagamento no meio do flow de checkout, não o checkout inteiro. Faz sentido quando o circuito precisa reagir a uma chamada de saída específica, com controle fino sobre qual chamada envolver e o que conta como falha.
O que o conector resolve
Uso típico, envolvendo a chamada que vai pro serviço de pagamento:
<circuit-breaker:execute circuitBreakerKey="payment-service" failureErrorTypes="HTTP:TIMEOUT,HTTP:CONNECTIVITY">
<http:request config-ref="payment-service-config" path="/authorize" method="POST" />
</circuit-breaker:execute>
Cada decisão de desenho do plugin resolve um problema concreto do cenário do checkout:
- Reage à degradação, não a uma falha isolada. O gatilho é a taxa de falha numa janela deslizante das últimas N chamadas, não um contador de falhas consecutivas. Numa sexta-feira de saldão, uma chamada lenta isolada é ruído; um serviço de pagamento genuinamente degradando é uma tendência. A janela deslizante pega a tendência sem abrir o circuito por causa de uma chamada azarada.
- Ninguém precisa lembrar de reportar o resultado. Uma única operação (
execute) consulta o estado, decide se a chamada segue, chama o serviço envolvido e reporta o resultado — sem depender de uma segunda chamada explícita do autor do flow. Sob pressão de prazo (e sexta-feira de saldão é sempre sob pressão), esquecer de reportar um resultado é o tipo de erro fácil de cometer e difícil de pegar em revisão de flow. - Um cartão recusado não é motivo pra desligar o checkout pra todo mundo. Só os tipos de erro configurados como falha de infraestrutura (
failureErrorTypes, por exemplo timeout e conectividade) contam para o circuito. Um cliente digitando um cartão vencido gera um erro de negócio — não deveria contar contra o serviço de pagamento nem afetar o checkout dos outros clientes. - Sabe que a sua aplicação não roda numa réplica só. O estado do circuito fica atrás de uma porta (
CircuitStateStore), com um adaptador padrão em Object Store do Mule Runtime. Isso é o que permite o próximo ponto: um circuito compartilhado entre réplicas, não uma ilusão de proteção por réplica.
Uma chamada bloqueada levanta CIRCUIT-BREAKER:OPEN, para o autor do flow tratar como qualquer outro erro tipado — por exemplo, devolvendo pro cliente “pagamento temporariamente indisponível, tente em alguns minutos” em vez de deixar o checkout travado esperando um timeout.
Voltando ao checkout de sexta-feira: com o circuit breaker plugado na chamada ao serviço de pagamento, as primeiras falhas ainda acontecem — não tem mágica que evite isso —, mas depois de um punhado delas, o circuito abre, os pedidos seguintes falham na hora com uma mensagem clara em vez de ficar esperando um timeout, e o serviço de pagamento para de receber tráfego que ele não tem como atender:
sequenceDiagram
participant C as Checkout
participant CB as circuit-breaker:execute
participant P as Serviço de pagamento
Note over C,P: Circuito CLOSED — tráfego normal
C->>CB: autorizar cartão
CB->>P: HTTP request
P-->>CB: 200 OK
CB-->>C: 200 OK
Note over C,P: Backend degrada — falhas se acumulam na janela
C->>CB: autorizar cartão
CB->>P: HTTP request
P-->>CB: HTTP:TIMEOUT
CB-->>C: erro (conta como falha)
Note over C,P: Circuito OPEN — backend não é mais chamado
C->>CB: autorizar cartão
CB-->>C: CIRCUIT-BREAKER:OPEN (imediato)
Ele volta a testar sozinho, a cada resetTimeout, sem precisar de ninguém reiniciando nada às 15h de uma sexta-feira de saldão.
A evidência local
A mule4-circuit-breaker-demo-app é uma aplicação Mule real — não um mock de teste — com um endpoint cliente que envolve um backend simulado no circuit-breaker:execute. O comportamento do backend é controlado por um header (X-Demo-Backend-Mode) que o cliente repassa, então “o backend se recupera” é só a próxima chamada mandar um header diferente. Alguns cenários, direto do README:
# Circuito fechado — passa normal
curl -i http://localhost:8081/orders/demo -H 'X-Demo-Backend-Mode: ok'
# Abre pela taxa de falha — as 5 primeiras chamadas são 502 reais;
# a 6ª já é 503, sem o backend ser chamado
for i in 1 2 3 4 5 6; do curl -i http://localhost:8081/orders/demo -H 'X-Demo-Backend-Mode: always_fail'; done
# Erro de negócio nunca abre o circuito — sempre 400, por mais que se repita
for i in 1 2 3 4 5; do curl -i http://localhost:8081/orders/demo -H 'X-Demo-Backend-Mode: business_error'; done
Chaves diferentes (checkout, shipping) isolam circuitos independentes, e o resetTimeout (10 s por padrão na demo) leva o circuito de OPEN para HALF_OPEN e de volta a CLOSED se a chamada de teste tiver sucesso.
Estado compartilhado entre réplicas: o que muda no CloudHub 2.0
A loja do checkout não roda numa réplica só em produção — o normal no CloudHub 2.0 é ter várias. Se cada réplica guardasse seu próprio estado de circuito, um cliente cairia numa réplica com o circuito aberto e outro cairia numa réplica que ainda nem percebeu que o serviço de pagamento caiu. É por isso que o estado fica atrás da porta CircuitStateStore: para que um circuito aberto numa réplica seja um fato compartilhado, não uma ilusão local.
A demo app foi implantada de verdade no CloudHub 2.0, com 2 réplicas de 0.1 vCore. Repetindo o cenário “abre pela taxa de falha” contra esse deploy, lendo X-Replica-Id pra saber qual réplica respondeu:
| Chamada | Réplica | Resultado |
|---|---|---|
| 1–8 | alternando entre as duas réplicas | 502 (falha real do backend) |
| 9 | uma réplica | 503 circuit_open |
| 10+ | ambas as réplicas, em momentos diferentes | 503 circuit_open |
O ponto central: nenhuma réplica sozinha chegou a 5 falhas — o circuito abriu pela soma das falhas das duas, e depois de aberto, as duas réplicas passaram a reportar circuit_open de forma independente, sem tornar a chamar o backend. Isso só é possível porque o estado vive no Object Store persistente e compartilhado do runtime, não na memória de cada réplica.
Essa mesma execução também reproduziu, ao vivo, dois limites que valem a pena conhecer antes de confiar cegamente no mecanismo:
- Sem lock entre réplicas. Logo depois do circuito reportar
circuit_opennuma réplica, a chamada seguinte — caindo na outra réplica — passou como falha normal, não bloqueada. O circuito reabriu uma ou duas chamadas depois. É uma corrida real: o lock do plugin só serializa leitura/escrita dentro de uma réplica; duas réplicas escrevendo perto uma da outra competem no nível do storage, e a escrita de uma pode ser sobrescrita pela outra. - Sem coordenação garantida na primeira escrita de uma chave nova. Com duas chamadas concorrentes contra uma chave nova, o circuito só abriu depois de 8 falhas reais, não das 5 configuradas. Não é uma prova formal — não dá para inspecionar as partições internas do Object Store de fora de um pod —, mas é consistente com o mesmo tipo de corrida: trate as primeiras chamadas de uma chave nova sob tráfego concorrente como não confiáveis para abrir exatamente no número configurado.
E o custo de tudo isso: +5,6 ms de mediana (+5,0 ms de média, +2,3 ms de p95) por chamada de circuit-breaker:execute, medido comparando um endpoint que passa pelo circuito breaker com um idêntico que não passa, sob carga ociosa — o custo de duas leituras-e-escrita travadas no Object Store por chamada, contra o backend gerenciado do CloudHub 2.0 pela rede.
Método completo, amostras cruas e o script de medição estão versionados no repositório da demo app, em evidence/BRU-58/.
Limites e quando não usar
- Sem lock entre réplicas. Réplicas escrevendo perto uma da outra competem no storage; a escrita de uma pode sobrescrever a da outra silenciosamente.
- Sem coordenação garantida na primeira escrita de uma chave, sob tráfego concorrente.
- Latência adicionada: +5,6 ms de mediana por chamada no CloudHub 2.0, medidos sob carga ociosa — não é zero, e cresce com o número de operações de Object Store por chamada.
- Depende do Object Store persistente do runtime implementar de verdade o compartilhamento entre réplicas — o plugin não replica estado por conta própria; ele confia no mecanismo que o CloudHub 2.0 já usa para isso.
Se a sua integração precisa de uma contagem exata sob concorrência pesada — não só “abre perto do limite configurado” —, este desenho não garante isso; precisaria de coordenação externa que nem este plugin nem o Object Store do Mule se propõem a dar.
Reproduza você mesmo
Localmente, sem depender de nenhuma conta de nuvem:
# Contraste retry-only vs. circuit breaker (mule4-circuit-breaker)
mvn test -Dtest=RetryVsCircuitBreakerDemoTest
# Suíte completa: máquina de estados, classificação de erro, storage
mvn clean test
# Empacotar e rodar a demo app localmente (mule4-circuit-breaker-demo-app)
mvn clean package
# copie o jar para <MULE_HOME>/apps/ e inicie o runtime, depois repita os curls das "Scenarios" do README
Se preferir validar num ambiente de nuvem em vez de local, os dois repositórios já vêm com azure-pipelines.yml prontos: publicar o plugin no Exchange e implantar a demo app no CloudHub 2.0 não exige nenhuma conta específica minha, só a mesma configuração descrita na série Entrega de aplicações MuleSoft com Azure DevOps — o passo a passo de variable group, environments e service connection está no README dos templates do Azure Pipelines, e o deploy no CloudHub 2.0 com aprovação está detalhado em Deploy no CloudHub 2.0 a partir do Azure Pipelines. A evidência de CloudHub 2.0 deste artigo — prova cross-réplica, a anomalia da primeira escrita e a medição de latência com script e amostras cruas — está em evidence/BRU-58/ no repositório da demo app.
Checklist
- O seu backend tem picos intermitentes ou uma degradação sustentada? Uma janela deslizante lida melhor com os dois do que um contador de falhas consecutivas.
- Existe mais de uma réplica rodando a mesma integração? Se sim, o estado do circuito precisa ser compartilhado, não só local.
- O retry está protegendo o cliente, ou empurrando mais carga contra um backend que já está caindo?
- O seu error handler distingue falha de infraestrutura de erro de negócio antes de decidir se aquilo conta para o circuito?