Um circuit breaker no Laravel é uma camada que conta as falhas recentes de um provedor externo e, passado um limite, para de chamá-lo por um tempo, devolvendo erro na hora em vez de esperar o timeout. Com o estado guardado no Redis, todos os workers e requisições web enxergam a mesma decisão, e o gateway de pagamento fora do ar deixa de arrastar o resto da aplicação junto. O que separa uma implementação útil de uma perigosa é definir bem o que conta como falha e o que fazer com operações financeiras quando o circuito abre.
Por que retry e timeout não bastam quando o gateway cai?
O cenário típico em quem integra com Pagar.me, Asaas, Zoop ou Stripe é este: o provedor não cai de vez, ele fica lento. Responde em 20, 30 segundos, ou devolve 502 e 503 de forma intermitente. Cada requisição de checkout segura um worker do PHP-FPM até o timeout. Cada job na fila tenta, espera, falha, volta para a fila e tenta de novo. Em poucos minutos:
- Os workers do FPM ficam todos presos esperando o gateway, e rotas que nem dependem dele (login, extrato, painel) começam a dar 504.
- Os workers da fila ficam ocupados com jobs de cobrança condenados a falhar, e jobs de outras naturezas (notificação, WhatsApp, conciliação) atrasam.
- O retry com backoff de cada job, somado, vira uma tempestade de requisições contra um provedor que já está sofrendo, o que atrasa a recuperação dele.
Timeout curto resolve o primeiro ponto só parcialmente: você ainda paga o timeout em cada chamada. Retry com backoff exponencial ajuda um job isolado, mas não coordena nada entre jobs. O que falta é memória compartilhada: "esse provedor falhou muito no último minuto, não vale a pena tentar agora". É exatamente isso que o circuit breaker faz.
Quais são os estados de um circuit breaker?
O padrão tem três estados. Fechado é o normal; aberto é o "nem tente"; meio-aberto é o período de teste depois da pausa.
| Estado | O que acontece com a chamada | Como sai do estado |
|---|---|---|
| Fechado | Passa normalmente; falhas são contadas numa janela de tempo | Se as falhas na janela passam do limite, abre |
| Aberto | É recusada na hora, sem tocar na rede | Depois de um tempo fixo (ex.: 30 s), vira meio-aberto |
| Meio-aberto | Só uma chamada de teste passa; as outras são recusadas | Sucesso fecha o circuito; falha reabre |
O meio-aberto é o detalhe que muita implementação caseira esquece. Sem ele, quando a pausa termina, todos os workers acordam ao mesmo tempo e despejam o backlog inteiro no provedor que mal voltou.
Como implementar um circuit breaker com Redis no Laravel?
Uso três chaves por provedor: um contador de falhas com TTL (a janela), uma chave open com TTL igual à pausa, e uma chave tripped que lembra que o circuito abriu recentemente. Quando open expira e tripped ainda existe, o circuito está meio-aberto. A chamada de teste é controlada com SET NX, que só grava se a chave não existir, garantindo que só um processo passe.
final class CircuitBreaker { public function __construct( private string $name, private int $failureThreshold = 5, private int $windowSeconds = 60, private int $openSeconds = 30, ) {} public static function for(string $provider): self { return new self($provider, ...config("circuits.$provider", [])); } public function allows(): bool { if (Redis::exists($this->key('open'))) { return false; // aberto } if (! Redis::exists($this->key('tripped'))) { return true; // fechado } // meio-aberto: só uma chamada de teste por vez return (bool) Redis::set($this->key('probe'), 1, 'EX', 15, 'NX'); } public function recordSuccess(): void { if (Redis::exists($this->key('tripped'))) { event(new CircuitClosed($this->name)); } Redis::del($this->key('tripped'), $this->key('failures'), $this->key('probe')); } public function recordFailure(): void { if (Redis::exists($this->key('tripped'))) { $this->trip(); // falhou no teste: reabre return; } $failures = Redis::incr($this->key('failures')); if ($failures === 1) { Redis::expire($this->key('failures'), $this->windowSeconds); } if ($failures >= $this->failureThreshold) { $this->trip(); } } private function trip(): void { Redis::set($this->key('open'), 1, 'EX', $this->openSeconds); Redis::set($this->key('tripped'), 1, 'EX', $this->openSeconds * 10); Redis::del($this->key('failures'), $this->key('probe')); event(new CircuitOpened($this->name)); } private function key(string $suffix): string { return "circuit:{$this->name}:{$suffix}"; } }
Duas observações sobre esse código. Primeiro, o par INCR + EXPIRE não é atômico; se o processo morrer entre os dois, o contador fica sem TTL. Na prática o próximo trip() limpa a chave, mas se isso te incomoda, troque por um script Lua ou por SET failures 0 EX 60 NX antes do INCR. Segundo, a janela é fixa, não deslizante. Para a maioria dos casos isso basta; janela deslizante com sorted set é mais precisa e mais cara.
O uso no cliente HTTP do provedor fica assim:
public function createCharge(ChargeData $data): GatewayResponse { $breaker = CircuitBreaker::for('pagarme'); if (! $breaker->allows()) { throw new GatewayUnavailable('pagarme'); } try { $response = Http::connectTimeout(2) ->timeout(8) ->withHeaders(['Idempotency-Key' => $data->idempotencyKey]) ->post($this->baseUrl . '/orders', $data->toPayload()); } catch (ConnectionException $e) { $breaker->recordFailure(); throw new GatewayUnavailable('pagarme', previous: $e); } if ($response->serverError() || $response->status() === 429) { $breaker->recordFailure(); throw new GatewayUnavailable('pagarme'); } $breaker->recordSuccess(); return GatewayResponse::fromPagarme($response); }
Se você já abstrai os provedores atrás de uma interface, como descrevi em como integrar vários gateways de pagamento sem amarrar o domínio a um provedor, o lugar natural do breaker é dentro de cada adapter, ou num decorator em volta dele. O domínio só vê GatewayUnavailable.
O que deve contar como falha no circuit breaker?
Essa é a decisão mais importante e a mais fácil de errar. A regra: só conta como falha o que indica que o provedor está doente, nunca o que indica que o pedido é inválido.
- Conta: erro de conexão, timeout, HTTP 500, 502, 503, 504.
- Conta, com cuidado: HTTP 429 (rate limit). O provedor está saudável, mas você está sendo limitado; abrir o circuito é uma forma razoável de dar respiro, desde que a pausa respeite o
Retry-After. - Não conta: 400, 401, 404, 422. Erro de validação, token errado ou recurso inexistente são bugs ou dados ruins seus.
- Nunca conta: cartão recusado, saldo insuficiente, antifraude negando. Isso é resposta de negócio bem-sucedida. Se você contar recusa como falha, uma sequência de cartões sem limite numa sexta à noite abre o circuito e derruba o checkout de quem tem cartão bom.
Um 401 em massa merece alerta, porque provavelmente alguém rotacionou uma chave, mas não é papel do breaker tratar isso.
Como usar o circuit breaker em jobs da fila sem queimar as tentativas?
Na fila o comportamento desejado é diferente: em vez de falhar, o job deve voltar para a fila e esperar o circuito fechar. Um job middleware (uma classe que envolve a execução do handle() do job) resolve isso:
final class RespectCircuitBreaker { public function __construct(private string $provider) {} public function handle(object $job, Closure $next): void { if (! CircuitBreaker::for($this->provider)->allows()) { $job->release(random_int(20, 40)); return; } $next($job); } }
O random_int adiciona jitter (variação aleatória no atraso) para os jobs não voltarem todos no mesmo segundo. A pegadinha: cada release() incrementa o contador de tentativas. Se o job tem $tries = 3, três passagens com o circuito aberto o mandam para failed_jobs sem nunca ter tocado no provedor. Por isso, em jobs protegidos pelo breaker, eu troco $tries por um limite de tempo e limito só as exceções reais:
final class CaptureCharge implements ShouldQueue { public int $maxExceptions = 3; public function retryUntil(): DateTime { return now()->addHours(2); } public function middleware(): array { return [new RespectCircuitBreaker('pagarme')]; } }
Vale dizer: o Laravel já traz o Illuminate\Queue\Middleware\ThrottlesExceptions (e a variante ThrottlesExceptionsWithRedis), que é um circuit breaker simplificado para jobs. Com ->by('pagarme') e ->when(fn ($e) => $e instanceof GatewayUnavailable) ele cobre bastante coisa. Se toda a sua integração com o provedor acontece na fila, comece por ele. Eu parto para a implementação própria quando há chamadas síncronas (checkout, geração de Pix na hora) que precisam compartilhar o mesmo estado com os jobs, ou quando quero o meio-aberto controlado e eventos de abertura e fechamento para alertas.
Como isolar filas por provedor no Horizon?
O circuit breaker decide se chama o provedor. O bulkhead, outro padrão de resiliência, decide quantos recursos cada provedor pode consumir, para que um deles lento não ocupe todos os workers. No Horizon isso é só configuração: uma fila e um supervisor por provedor, com maxProcesses limitado.
'environments' => [ 'production' => [ 'gateway-pagarme' => [ 'connection' => 'redis', 'queue' => ['pagarme'], 'maxProcesses' => 6, 'timeout' => 30, ], 'gateway-asaas' => [ 'connection' => 'redis', 'queue' => ['asaas'], 'maxProcesses' => 4, 'timeout' => 30, ], 'default' => [ 'connection' => 'redis', 'queue' => ['notifications', 'default'], 'maxProcesses' => 10, ], ], ],
O timeout do supervisor precisa ser maior que o timeout do cliente HTTP, senão o Horizon mata o worker no meio da requisição e você perde a resposta. Com essa separação, mesmo antes de o breaker abrir, a degradação de um provedor fica confinada aos seis processos dele. Notificações e jobs de outros produtos seguem andando.
O que fazer com um pagamento quando o circuito abre?
Aqui o padrão genérico encontra a realidade de sistemas financeiros, e é onde mora o risco. A tentação é fazer fallback automático: Pagar.me abriu, manda para o Asaas. Para algumas operações isso é ótimo; para outras, é cobrança duplicada.
A pergunta a fazer é: a última tentativa pode ter chegado ao provedor? Se o circuito estava aberto e a chamada foi recusada localmente, ela nunca saiu da sua aplicação, e trocar de provedor é seguro. Se a chamada que falhou foi um timeout, o estado é ambíguo: o provedor pode ter criado a cobrança e só não respondeu a tempo. Mandar a mesma cobrança para outro gateway nesse caso é cobrar duas vezes. O que funciona:
- Recusa local (circuito aberto): pode tentar outro provedor ou devolver erro amigável na hora. Nada foi enviado.
- Timeout ou erro de conexão depois do envio: marque a operação como pendente de confirmação e reenvie depois, no mesmo provedor, com a mesma Idempotency-Key. Expliquei o mecanismo em como evitar cobrança duplicada com Idempotency-Key quando o gateway dá timeout.
- 5xx explícito: depende da documentação do provedor. Muitos garantem que 5xx não persistiu nada; outros não garantem. Na dúvida, trate como timeout.
- Operações de saída de dinheiro (Pix out, TED, repasse de split): nunca faça fallback automático entre provedores. Enfileire e espere o circuito fechar. Atraso num saque gera ticket de suporte; saque duplicado gera prejuízo.
Na interface, o circuito aberto vira uma mensagem honesta ("o pagamento por cartão está instável, tente Pix ou aguarde alguns minutos") em vez de um spinner de 30 segundos. E o que ficou pendente precisa ser resolvido por um processo que consulta o provedor quando ele volta. A conciliação diária entre o extrato do provedor e o seu banco é a rede de segurança final para qualquer caso que escapou.
Como monitorar o circuit breaker em produção?
Um circuito que abre e fecha em silêncio é pior que nenhum, porque mascara o problema. O mínimo que configuro:
- Listener em
CircuitOpenedque avisa no canal de alertas com o nome do provedor e o horário. É o alerta mais útil que tenho sobre provedores externos, porque costuma chegar antes da página de status deles. - Listener em
CircuitClosedque registra quanto tempo o circuito ficou aberto. - Contador de chamadas recusadas localmente por provedor. Se o circuito abre várias vezes por dia com poucas recusas, o limite está sensível demais.
- Tamanho da fila de cada provedor no Horizon. Com o breaker aberto, ela cresce; quando fecha, precisa drenar em ritmo controlado.
Quais são os trade-offs de configurar o circuit breaker?
Não existe limite universal. Os ajustes que mais pesam:
| Decisão | Se for agressiva demais | Se for frouxa demais |
|---|---|---|
| Limite de falhas | Abre por ruído normal e recusa vendas boas | Demora a abrir e os workers já travaram |
| Tempo aberto | Provedor já voltou e você segue recusando | Workers acordam cedo demais e reabrem o circuito |
| Janela de contagem | Junta falhas sem relação entre si | Nunca acumula falhas suficientes |
| Escopo do circuito | Um endpoint ruim derruba o provedor inteiro | Falha geral do provedor demora a ser detectada |
Contagem absoluta de falhas funciona em volume baixo e médio. Com volume alto, cinco falhas por minuto podem ser só a taxa normal de erro; aí vale migrar para proporção (por exemplo, mais de 50% de falhas com um mínimo de 20 chamadas na janela). Sobre escopo, costumo ter um circuito por provedor e, quando o provedor tem produtos com infraestrutura claramente separada (cartão e Pix, por exemplo), um por produto. Um circuito por endpoint costuma ser granular demais.
Conclusão: por onde começar
Se você integra com gateway de pagamento e ainda não tem nada disso, a ordem que eu seguiria é: primeiro, timeouts explícitos e curtos em todo Http:: que fala com provedor; segundo, fila e supervisor separados por provedor no Horizon, que é só configuração e já confina o estrago; terceiro, o circuit breaker no Redis com uma lista clara do que conta como falha, deixando recusa de cartão de fora; quarto, os jobs trocando $tries por retryUntil() para não morrerem enquanto o circuito está aberto. E antes de ligar qualquer fallback automático entre provedores, escreva num papel quais operações podem trocar de gateway com segurança e quais precisam esperar. Em sistema financeiro, a lista das que precisam esperar é sempre maior do que parece.