hugorutemberg/service-circuit-breaker

Circuit breaker leve para APIs externas instáveis. Estado compartilhado em qualquer cache PSR-6, janela deslizante de falhas, log rolante de erros agrupados e status pronto para dashboard.

Maintainers

Package info

github.com/hugorutemberg/service-circuit-breaker

pkg:composer/hugorutemberg/service-circuit-breaker

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-14 19:27 UTC

This package is not auto-updated.

Last update: 2026-08-15 17:46:40 UTC


README

CI Packagist PHP License

Circuit breaker para APIs externas instáveis. Estado compartilhado em qualquer cache PSR-6, janela deslizante de falhas, log rolante de erros agrupados e status pronto para dashboard.

Sem dependência de cliente HTTP. Sem framework. PHP 8.1+.

O problema

Uma API de terceiro para de responder. Cada request da sua aplicação fica preso até o timeout, segurando worker, conexão de banco e lock de sessão. Em poucos segundos o pool de workers esgota e a aplicação inteira cai por causa de uma dependência que nem era crítica.

O breaker corta isso: as primeiras falhas abrem o circuito e todas as chamadas seguintes falham na hora, sem tocar a rede, até a janela expirar.

Isto já não existe?

Existe, e o principal é o ackintosh/ganesha: maduro, 4,4 milhões de downloads, mantido. Se você precisa de um circuit breaker em PHP e não quer pensar no assunto, use o Ganesha.

Este pacote nasceu de uma necessidade específica que o Ganesha não cobre: além de abrir e fechar o circuito, eu precisava de um painel de status por dependência, com log rolante de erros agrupados por causa, para responder "o que está quebrando agora e por quê" sem abrir arquivo de log. Daí vieram status(), topOccurrences() e a separação entre falha de saúde e recusa da API.

As outras diferenças em relação ao Ganesha: janela deslizante em vez de contador com TTL, relógio PSR-20 injetável para testar sem sleep(), redação de segredos nas mensagens, e nenhuma dependência além de interfaces PSR.

Instalação

composer require hugorutemberg/service-circuit-breaker

Uso

use Hugorutemberg\CircuitBreaker\CircuitBreaker;
use Hugorutemberg\CircuitBreaker\Exception\CircuitOpenException;

$breaker = new CircuitBreaker($cachePool); // qualquer CacheItemPoolInterface

try {
    $cotacao = $breaker->call('transportadora', fn () => $client->cotar($payload));
} catch (CircuitOpenException $e) {
    // A API está fora do ar e nenhuma chamada de rede foi feita.
    // Responda com o cache, com uma tabela offline, ou pule esta opção.
    $cotacao = $tabelaOffline->cotar($payload);
}

call() executa a operação sob proteção do circuito: se estiver aberto, lança CircuitOpenException sem rodar o callable; se a operação falhar, registra a ocorrência e propaga a exceção original.

Prefere controlar na mão? A API crua também está disponível:

if ($breaker->isOpen('transportadora')) {
    return $tabelaOffline->cotar($payload);
}

try {
    $resposta = $client->cotar($payload);
    $breaker->recordSuccess('transportadora');
} catch (ConnectException $e) {
    $breaker->recordFailure('transportadora', $e->getMessage());
    throw $e;
}

Falha de saúde não é o mesmo que recusa da API

É a distinção que mais importa aqui, e a que a maioria das implementações não faz.

Método Quando usar Abre o circuito?
recordFailure() timeout, DNS, conexão recusada, 5xx Sim, ao atingir o limite de strikes
recordError() 4xx, payload recusado, credencial inválida Nunca

Um CEP inválido em um pedido não pode derrubar a integração para todos os outros. A API está de pé, respondendo, e apenas recusou aquele payload. Contar isso como falha de saúde abre o circuito no meio de um lote e transforma o erro de um cliente em uma queda generalizada.

Os dois alimentam o painel de status. Só o primeiro afeta o circuito.

Ao usar call(), um classificador faz essa separação:

use Hugorutemberg\CircuitBreaker\Occurrence;

$breaker->call(
    'transportadora',
    fn () => $client->cotar($payload),
    fn (\Throwable $e) => $e->getCode() >= 400 && $e->getCode() < 500
        ? Occurrence::TYPE_ERROR
        : Occurrence::TYPE_FAILURE,
);

Janela deslizante, não contador com TTL

A implementação ingênua é um contador com TTL de 60 segundos. Ela tem um furo: se o contador foi criado no segundo 0, três falhas nos segundos 55, 58 e 61 não abrem o circuito, porque a expiração zerou a contagem no meio da rajada.

Aqui a janela guarda os instantes das falhas e descarta o que saiu do intervalo. Três falhas em qualquer 60 segundos corridos abrem o circuito, independente de onde a janela começou.

recordSuccess() zera os strikes, para que uma falha isolada a cada poucos minutos nunca some até disparar.

Estado compartilhado entre processos

O estado mora no cache PSR-6, então é o mesmo para request web, worker de fila, comando de CLI e cron. O processo que detecta a queda protege todos os outros, inclusive os que ainda nem tentaram a chamada.

Com ArrayAdapter o breaker funciona, mas só dentro do processo. Para valer entre processos, use Redis, Memcached ou APCu compartilhado.

Degradação quando o cache cai

O breaker existe para proteger a aplicação de uma dependência instável. Se ele próprio explodisse quando o Redis cai, seria mais um ponto de falha em vez de uma proteção.

Toda operação de cache é best-effort: falha vira log de warning e o breaker opera como circuito fechado, deixando a chamada seguir para a rede. Você perde a proteção, nunca a aplicação. Conteúdo corrompido no cache é tratado da mesma forma.

Segredos nas mensagens de erro

recordFailure($servico, $e->getMessage()) é o uso natural, e mensagem de exceção de cliente HTTP costuma trazer a URL completa. Se a API usa credencial em query string, o token iria para o cache, para o log e para a tela de status.

Toda mensagem passa por ReasonRedactor antes de ser gravada:

antes:  cURL error 28 em https://api.exemplo.com/cotar?api_key=sk_live_9f3a2b7c1d&cep=01310100
depois: cURL error 28 em https://api.exemplo.com/cotar?api_key=[oculto]&cep=01310100

Ele cobre credencial em URL, JWT, cabeçalho Authorization e campos como api_key, token, secret, password e client_secret. O que interessa para diagnosticar, status e endpoint, continua legível.

É best-effort e não autoriza credencial em URL. Existe porque a prática correta falha na vida real. Para formatos próprios, estenda a classe. Detalhes em SECURITY.md.

Painel de status

$status = $breaker->status(['transportadora', 'pagamentos', 'fiscal']);

foreach ($status as $nome => $servico) {
    echo $nome, ': ', $servico->health(), PHP_EOL; // up | degraded | down
}

degraded é o estado que costuma passar despercebido: o circuito está fechado, mas houve falha de rede nos últimos 30 minutos. É o aviso antes da queda.

Cada ServiceStatus traz o estado atual, o último disparo, o último erro, os contadores do dia e toArray() para jogar direto num JSON de healthcheck.

Principais erros

O log rolante guarda as últimas 50 ocorrências por serviço. topOccurrences() agrupa pela parte estável da mensagem, descartando o payload que varia a cada request:

foreach ($breaker->topOccurrences('transportadora') as $grupo) {
    printf("%dx  %s%s", $grupo->count, $grupo->key, PHP_EOL);
}
23x  HTTP 400 em /pedidos (ORD-014)
9x   cURL error 28
2x   HTTP 503 em /pedidos

Três respostas com o mesmo status e o mesmo código, e payloads diferentes, são o mesmo problema. Sem o agrupamento, a lista vira 34 linhas ilegíveis.

Religar e testar

$breaker->reset('transportadora'); // botão "religar" do painel

$resultado = $breaker->probe('transportadora', fn () => $client->ping());
// $resultado->reachable, ->latencyMs, ->message

probe() ignora o estado do circuito de propósito: serve justamente para checar se a dependência já voltou antes de religar. Qualquer retorno conta como "no ar", porque até um 401 prova que o serviço está respondendo. Só exceção conta como fora do ar.

Configuração

use Hugorutemberg\CircuitBreaker\BreakerConfig;

$breaker = new CircuitBreaker(
    cache: $cachePool,
    logger: $logger,
    config: new BreakerConfig(
        openSeconds: 120,          // tempo aberto após disparar
        strikesToTrip: 3,          // falhas para abrir
        strikeWindowSeconds: 60,   // tamanho da janela deslizante
        errorLogMax: 50,           // ocorrências guardadas por serviço
    ),
    namespace: 'circuit_breaker',  // prefixo das chaves de cache
);

Nomes de serviço com caracteres reservados pela PSR-6 (api/v2:pedidos) são aceitos: a chave vira um hash estável, preservando o isolamento entre serviços.

Symfony

# config/services.yaml
services:
    Hugorutemberg\CircuitBreaker\CircuitBreaker:
        arguments:
            $cache: '@cache.app'
            $logger: '@logger'

Testes

O relógio é injetável (PSR-20), então os testes de expiração e de janela deslizante rodam sem sleep():

$clock = new FrozenClock();
$breaker = new CircuitBreaker(cache: $cache, clock: $clock);

$breaker->trip('api', 'fora do ar');
$clock->advance(121);

$breaker->isOpen('api'); // false
composer install
composer test
composer analyse

Licença

MIT.