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:

  1. Os workers do FPM ficam todos presos esperando o gateway, e rotas que nem dependem dele (login, extrato, painel) começam a dar 504.
  2. 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.
  3. 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.

  1. Conta: erro de conexão, timeout, HTTP 500, 502, 503, 504.
  2. 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.
  3. Não conta: 400, 401, 404, 422. Erro de validação, token errado ou recurso inexistente são bugs ou dados ruins seus.
  4. 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:

  1. Recusa local (circuito aberto): pode tentar outro provedor ou devolver erro amigável na hora. Nada foi enviado.
  2. 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.
  3. 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.
  4. 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:

  1. Listener em CircuitOpened que 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.
  2. Listener em CircuitClosed que registra quanto tempo o circuito ficou aberto.
  3. 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.
  4. 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.