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.
Package info
github.com/hugorutemberg/service-circuit-breaker
pkg:composer/hugorutemberg/service-circuit-breaker
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
This package is not auto-updated.
Last update: 2026-08-15 17:46:40 UTC
README
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.