Destino Dev

Blog

Circuit breaker para APIs externas com Laravel Http Client e Redis

Por John Pires · 29 de março de 2026

Circuit breaker para APIs externas com Laravel Http Client e Redis

Cenario pratico do dia a dia

Problema real: chamadas externas falhando

Você tem um serviço Laravel que consome uma API externa (pagamentos, CEP, validação de documentos). Quando a API externa começa a falhar, suas requisições disparam erros, filas enchem, latência sobe e a aplicação inteira degrada. Isso acontece frequentemente em produção e os desenvolvedores colocam retry simples com sleep em qualquer lugar — o problema piora.

Impacto em sistemas distribuídos

Em uma arquitetura com múltiplas instâncias do Laravel (horizontal scaling), um flag em memória não resolve: uma instância pode bloquear chamadas enquanto outra continua massacrando o endpoint externo. Precisamos de um estado compartilhado com consistência suficiente para tomar decisões rápidas: é aqui que Redis entra como a camada de coordenação.

Este cenário assume: Laravel 10+, PHP 8.3+, Redis como provider central, e uso do Http Client do Laravel (Illuminate\Support\Facades\Http).

Tip: circuit breaker não é só evitar chamadas; é proteger capacidade do seu sistema e permitir recuperação controlada.

Arquitetura da solucao

Estado e transições (CLOSED, OPEN, HALF_OPEN)

O padrão define três estados: CLOSED (tudo normal), OPEN (não tentar a API por um período) e HALF_OPEN (permitir algumas tentativas para verificar se o serviço voltou). Implementaremos essas transições com chaves Redis e operações atômicas.

Onde a lógica vive — borda vs core

Colocar a lógica do circuit breaker em todos os lugares é erro. A solução ideal centraliza a lógica em um serviço (por exemplo, App\Services\CircuitBreakerService) e expõe uma API simples: allowRequest(), recordSuccess(), recordFailure(). Chamadas externas passam por esse serviço.

Na prática, teremos três componentes:

  • Classe CircuitBreaker (controle em Redis)
  • Wrapper HTTP que integra com Http:: (faz checagem antes de cada chamada)
  • Fallbacks/handlers para casos em que Redis fica inacessível

Codigo completo com explicacoes

Jeito errado: implementação ad-hoc na controller

Exemplo comum: catch/try com flag em memória e retries com sleep. Funciona localmente, rompe em produção.

Contexto: este código é encontrado em controllers e cria problemas em múltiplas instâncias e quando a falha é prolongada.

<?php
// controllers/PaymentController.php (errado)
// Faz retries inline e seta uma flag em memória
namespace App\Http\Controllers;

use Illuminate\Support\Facades\Http;
use Illuminate\Http\Request;

class PaymentController extends Controller
{
    private bool $externalDown = false; // apenas em memória

    public function charge(Request $request)
    {
        if ($this->externalDown) {
            return response()->json(['error' => 'external service down'], 503);
        }

        try {
            for ($i = 0; $i < 3; $i++) {
                $resp = Http::timeout(2)->post('https://api.example.com/charge', $request->all());
                if ($resp->successful()) {
                    return $resp->json();
                }
                sleep(1);
            }
        } catch (\Exception $e) {
            $this->externalDown = true; // só para a instância atual
            return response()->json(['error' => 'request failed'], 502);
        }

        return response()->json(['error' => 'max retries reached'], 502);
    }
}

O que acontece depois: quando a API externa cai, cada instância faz retries e aumenta carga na API. Flag em memória não impede outras instâncias.

Jeito certo: CircuitBreaker centralizado com Redis

Vamos implementar uma classe que usa Redis para gerenciar estado, um wrapper para chamadas Http e um ServiceProvider opcional para injeção. A ênfase é atomicidade e baixo número de chamadas Redis por operação.

Contexto: classe usa keys por nome de serviço e mantém contador de falhas, estado e timestamps. Usa Lua script para transições atômicas.

<?php
// app/Services/CircuitBreaker.php (trecho principal)
namespace App\Services;

use Illuminate\Support\Facades\Redis;
use Carbon\Carbon;

class CircuitBreaker
{
    private string $name;
    private int $failureThreshold;
    private int $openTimeout; // seconds
    private int $halfOpenMaxRequests;

    public function __construct(string $name, int $failureThreshold = 5, int $openTimeout = 60, int $halfOpenMaxRequests = 2)
    {
        $this->name = $name;
        $this->failureThreshold = $failureThreshold;
        $this->openTimeout = $openTimeout;
        $this->halfOpenMaxRequests = $halfOpenMaxRequests;
    }

    private function key(string $suffix): string
    {
        return "cb:{$this->name}:{$suffix}";
    }

    public function allowRequest(): bool
    {
        $state = Redis::get($this->key('state')) ?: 'CLOSED';

        if ($state === 'OPEN') {
            $openedAt = (int) Redis::get($this->key('opened_at'));
            if ($openedAt + $this->openTimeout <= time()) {
                // transit to HALF_OPEN
                Redis::set($this->key('state'), 'HALF_OPEN');
                Redis::del($this->key('half_open_successes'));
                Redis::del($this->key('half_open_attempts'));
                return true;
            }
            return false;
        }

        if ($state === 'HALF_OPEN') {
            $attempts = (int) Redis::incr($this->key('half_open_attempts'));
            if ($attempts > $this->halfOpenMaxRequests) {
                // limit concurrent half-open attempts
                Redis::decr($this->key('half_open_attempts'));
                return false;
            }
            return true;
        }

        // CLOSED
        return true;
    }

    public function recordSuccess(): void
    {
        $state = Redis::get($this->key('state')) ?: 'CLOSED';
        if ($state === 'HALF_OPEN') {
            $successes = Redis::incr($this->key('half_open_successes'));
            if ($successes >= $this->halfOpenMaxRequests) {
                // close the breaker
                Redis::set($this->key('state'), 'CLOSED');
                Redis::del($this->key('failures'));
                Redis::del($this->key('half_open_attempts'));
                Redis::del($this->key('half_open_successes'));
            }
            return;
        }

        // CLOSED -> reset failures on success
        Redis::del($this->key('failures'));
    }

    public function recordFailure(): void
    {
        $failures = (int) Redis::incr($this->key('failures'));
        if ($failures === 1) {
            // set an expiry to count windowed failures
            Redis::expire($this->key('failures'), $this->openTimeout);
        }

        if ($failures >= $this->failureThreshold) {
            // open the circuit
            Redis::set($this->key('state'), 'OPEN');
            Redis::set($this->key('opened_at'), time());
        }
    }
}

O que acontece depois: controle central de estado em Redis, múltiplas instâncias respeitam o mesmo estado e evitam tiros de misericórdia à API. A implementação acima é simples, explícita e suficiente para muitas necessidades.

Integrando com Laravel Http Client

Vamos expor um helper que usa o CircuitBreaker para proteger chamadas. Ele chama allowRequest() antes do Http:: e registra sucesso/falha após a resposta/exception.

<?php
// app/Services/ExternalHttpClient.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ExternalHttpClient
{
    private CircuitBreaker $cb;

    public function __construct(CircuitBreaker $cb)
    {
        $this->cb = $cb;
    }

    public function post(string $url, array $payload)
    {
        if (! $this->cb->allowRequest()) {
            // fallback imediato: pode ser queue, cache, ou erro controlado
            return response()->json(['error' => 'service temporarily unavailable'], 503);
        }

        try {
            $resp = Http::timeout(5)->post($url, $payload);

            if ($resp->successful()) {
                $this->cb->recordSuccess();
                return $resp;
            }

            // 4xx/5xx contam como falha
            $this->cb->recordFailure();
            return $resp;
        } catch (\Exception $e) {
            Log::warning('External call failed: '.$e->getMessage());
            $this->cb->recordFailure();
            throw $e; // ou retornar fallback
        }
    }
}

Contexto pós-código: este wrapper centraliza retries e circuit breaker, facilita testes e permite trocar estratégias sem mudar controllers.

Tratamento de erros e edge cases

Redis indisponível — como proceder

Se Redis falhar, o circuit breaker perde a fonte de verdade. Escolha um modo de degradação: fail-open (permitir chamadas) ou fail-closed (bloquear todas). Recomendo fail-open com limites de tempo e coleta de métricas, porque fail-closed pode derrubar serviço inteiro se Redis estiver temporariamente fora.

Condições de corrida e atomicidade

Operações como incrementar contador e verificar threshold devem ser atômicas. Em implementações simples usamos INCR + EXPIRE; isso é aceitável mas tem janelas. Para produção sensível, use Lua scripts no Redis para executar lógica composta em um único round-trip.

<?php
// exemplo de Lua script executado via Redis::eval para atomically incrementar e abrir circuit
$lua = <<<'LUA'
local failures_key = KEYS[1]
local state_key = KEYS[2]
local opened_key = KEYS[3]
local threshold = tonumber(ARGV[1])
local open_timeout = tonumber(ARGV[2])

local failures = redis.call('INCR', failures_key)
if failures == 1 then
  redis.call('EXPIRE', failures_key, open_timeout)
end

if failures >= threshold then
  redis.call('SET', state_key, 'OPEN')
  redis.call('SET', opened_key, tonumber(redis.call('TIME')[1]))
end

return failures
LUA;

$result = Redis::eval($lua, 3, 'cb:payments:failures', 'cb:payments:state', 'cb:payments:opened_at', 5, 60);
var_dump($result);

Explicação: o script faz INCR + EXPIRE e seta estado OPEN com timestamp em um único passo, evitando condições de corrida entre comandos separados.

Half-open com concorrência

Quando muitos processos entram em HALF_OPEN ao mesmo tempo, você precisa limitar tentativas (halfOpenMaxRequests) e/ou usar tokens. O código acima usa contador 'half_open_attempts' para limitar paralelismo.

Erros comuns:

  • Esquecer de limpar counters quando reabrir
  • Contar 4xx como sucesso — defina claramente quais códigos contam como falha
  • Ignorar latências altas que também podem indicar degradação

Performance e otimizacao

Reduzir chamadas Redis

Evite múltiplos comandos Redis por request. Use pipeline quando possível ou scripts Lua para agrupar lógica. Também armazene chaves com TTL apropriado para não poluir namespace.

Estratégias de retry e backoff

Retry indiscriminado é perigoso. Se decidir retry, use backoff exponencial com jitter e contador máximo. Melhor alternativa: deixe o circuit breaker controlar quando retriar através do HALF_OPEN.

Exemplo: retry local com backoff (se usar):

<?php
// exemplo de retry simples com backoff exponencial e jitter
$attempt = 0;
$max = 3;
while ($attempt < $max) {
    try {
        $resp = Http::timeout(3)->post('https://api.example.com/endpoint', $data);
        if ($resp->successful()) break;
    } catch (\Exception $e) {
        // log
    }

    $attempt++;
    $sleep = (int) (pow(2, $attempt) + rand(0, 100) / 100.0);
    usleep($sleep * 1000 * 1000);
}

Contexto após o código: retries devem ser pequenos e condicionais; use o circuit breaker para decidir se vale a pena tentar novamente.

Monitoramento e métricas

Não basta proteger chamadas; monitore: contagem de falhas, tempo médio de resposta, taxa de abertura do circuito. Exporte métricas para Prometheus/StatsD. Alarmes para estados OPEN persistentes são essenciais.

Conclusao pratica

Implementar circuit breaker com Redis e Laravel Http Client reduz riscos de cascata e torna seu consumo de APIs externas previsível. Evite soluções ad-hoc em memória; prefira estado compartilhado e operações atômicas.

Checklist mínimo para implementar em produção:

  1. Classe centralizada CircuitBreaker com Redis
  2. Wrapper HTTP que faz allowRequest/recordSuccess/recordFailure
  3. Usar scripts Lua quando precisar de atomicidade entre operações
  4. Limitar concorrência em HALF_OPEN
  5. Degradação segura quando Redis estiver indisponível
  6. Métricas e alertas

Erros de implementação aumentam latência e podem derrubar terceiros — esse artigo mostrou o caminho errado e o certo com exemplos práticos em PHP/Laravel. Aplique essas práticas, escreva testes integrados e monitore após o deploy.

Implementar circuit breaker não é rocket science, mas requer disciplina: centralizar lógica, tratar edge cases e instrumentar. Faça code reviews focados nesses pontos e prefira implementações simples e observáveis.

Assuntos: Laravel, PHP, NoSQL, http-client, circuit-breaker, best-practices

Leia também