# Circuit breaker no Laravel com Redis: como isolar um gateway de pagamento fora do ar

> Quando o provedor de pagamento degrada, retry e timeout não bastam. Como montar um circuit breaker com Redis no Laravel, isolar filas no Horizon e decidir o que fazer com o circuito aberto.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-14 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/circuit-breaker-no-laravel-com-redis-como-isolar-um-gateway-de-pagamento-fora-do-ar

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.

```php
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:

```php
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](/blog/como-integrar-varios-gateways-de-pagamento-no-laravel-sem-amarrar-o-dominio-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:

```php
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:

```php
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.

```php
'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](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel).
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](/blog/conciliacao-de-pagamentos-no-laravel-como-bater-o-extrato-do-provedor-com-o-seu-banco-todo-dia) é 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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
