rasuvaeff/circuit-breaker

Circuit breaker resilience primitive for PHP with pluggable in-memory, APCu, and Redis storage backends

Maintainers

Package info

github.com/rasuvaeff/circuit-breaker

pkg:composer/rasuvaeff/circuit-breaker

Transparency log

Statistics

Installs: 173

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-08-21 19:56 UTC

This package is auto-updated.

Last update: 2026-08-27 16:25:17 UTC


README

Latest Stable Version Total Downloads Build Static analysis Psalm level PHP License Русская версия

Circuit breaker resilience primitive: protects a downstream dependency from cascading failure. Closed → Open → HalfOpen state, switched by sliding-window success/failure counts. In Open, calls fail fast — no network round-trip — until a cooldown elapses; a bounded number of HalfOpen probes then decide whether to resume or reopen.

Pluggable distributed state via Storage: in-memory (tests/CLI), APCu (single host), Redis (multi-host cluster) — all three implement the exact same atomic admit()/recordOutcome() contract, so switching backends never changes behavior, only how far the shared state reaches.

Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model. Projects using the llm/skills Composer plugin also get this package's agent skill synced into .agents/skills/ automatically on install.

Requirements

  • PHP 8.3+
  • rasuvaeff/duration for the typed cooldown/window values
  • psr/clock — inject any ClockInterface; Clock\SystemClock (production) and Clock\FakeClock (tests) are included
  • For multi-host state (RedisStorage): a reachable Redis server plus one Redis client — predis/predis ^2.2 (pure-PHP, PredisScriptRunner) or ext-redis (PhpRedisScriptRunner). Both are optional; install the one you use.
  • ext-apcu for single-host state (ApcuStorage) — optional, not a hard dependency

Installation

composer require rasuvaeff/circuit-breaker

# for RedisStorage with the pure-PHP client:
composer require predis/predis

Usage

use Psr\Http\Client\ClientExceptionInterface;
use Rasuvaeff\CircuitBreaker\BreakerConfig;
use Rasuvaeff\CircuitBreaker\CircuitBreaker;
use Rasuvaeff\CircuitBreaker\Clock\SystemClock;
use Rasuvaeff\CircuitBreaker\InMemoryStorage;
use Rasuvaeff\CircuitBreaker\Ratio;
use Rasuvaeff\Duration\Duration;

$cb = new CircuitBreaker(
    config: new BreakerConfig(
        name: 'stripe',
        failureThreshold: Ratio::of(failures: 5, window: 10, within: Duration::seconds(60)),
        cooldown: Duration::seconds(30),   // how long Open lasts before a probe is allowed
        successThreshold: 3,               // consecutive probe successes to close again
        // Classify only exceptions that indicate a downstream failure.
        isFailure: static fn(\Throwable $e): bool => $e instanceof ClientExceptionInterface,
    ),
    storage: new InMemoryStorage(), // or ApcuStorage / RedisStorage - same contract
    clock: new SystemClock(),
);

$charge = $cb->call(
    callback: static fn(): mixed => $stripe->charges->create([/* ... */]),
    fallback: static fn(\Throwable $e): mixed => ChargeResult::queuedForRetry(),
);

if ($cb->state()->value === 'open') {
    // show a degraded UI without attempting the call
}

With Redis (multi-host):

use Predis\Client;
use Rasuvaeff\CircuitBreaker\Redis\PredisScriptRunner;
use Rasuvaeff\CircuitBreaker\RedisStorage;

$storage = new RedisStorage(new PredisScriptRunner(new Client(['host' => '127.0.0.1'])));

With ext-redis instead of predis:

use Rasuvaeff\CircuitBreaker\Redis\PhpRedisScriptRunner;

$redis = new \Redis();
$redis->connect('127.0.0.1');
$storage = new RedisStorage(new PhpRedisScriptRunner($redis));

With APCu (single host):

use Rasuvaeff\CircuitBreaker\ApcuStorage;

$storage = new ApcuStorage();

Public API

Type Description
CircuitBreaker call(callable, ?callable $fallback): mixed, canCall(), state(), metrics(), forceOpen(), forceClosed()
BreakerConfig name, failureThreshold (Ratio), cooldown, successThreshold, required isFailure, probeLimit, probeTimeout
Ratio "N failures out of the last M calls, within a sliding window" — backs failureThreshold
CircuitState Enum: Closed, Open, HalfOpen
Outcome Enum: Success, Failure, Ignored — result of isFailure() classification
Admission Enum: Allowed, Probe, RejectedStorage::admit()'s decision
Storage Interface: admit, recordOutcome, snapshot, forceState — the distributed-state seam. admit()/recordOutcome() require the fencing triple (admission, admittedAt, attemptId)
StorageFailure Infrastructure exception for storage outages; exposes operation, breakerName, the original exception, and downstreamOutcome (the callback's exception when storage failed while recording it)
InMemoryStorage Single-process store (tests/CLI); no cross-process coordination
ApcuStorage Single-host cross-process store; apcu_add lock (lease, lockTtlSeconds) around the whole read-transition-write
RedisStorage Multi-host cross-process store; one Lua script per Storage method
CircuitScriptRunner Typed seam over a Redis script call (implement for another client)
Redis\PredisScriptRunner predis-backed CircuitScriptRunner; EVALSHA with EVAL fallback
Redis\PhpRedisScriptRunner ext-redis-backed CircuitScriptRunner; EVALSHA with EVAL fallback
StateRecord Snapshot returned by Storage: state, openedAt, successes, failures, rejected
Metrics Observability snapshot from CircuitBreaker::metrics(), mirrors StateRecord
CircuitTransition A committed state change: breakerName, from, to, occurredAt, reason, state
TransitionReason Enum: why a transition happened — FailureThresholdReached, CooldownElapsed, ProbeSucceeded, ProbeFailed, ForcedOpen, ForcedClosed, ForcedHalfOpen
CircuitObserver Interface receiving committed CircuitTransition events; paired with an error handler
CircuitOpenException Thrown (or passed to fallback) when a call is rejected; carries breakerName, retryAfter
Clock\SystemClock Psr\Clock\ClockInterface using the wall clock
Clock\FakeClock Controllable clock for testing cooldown/window expiry

How call() decides

  1. Storage::admit() — atomically applies the Open → HalfOpen cooldown transition, then decides Allowed (Closed) / Probe (HalfOpen, slot occupied) / Rejected (Open, or HalfOpen with no free probe slot).
  2. Rejected$callback is never invoked. $fallback, if given, receives a fresh CircuitOpenException; otherwise it is thrown. In Open, retryAfter is the cooldown deadline; for a saturated HalfOpen, it is a conservative deadline based on probeTimeout.
  3. Allowed/Probe$callback runs. Its outcome is classified via isFailure() into Success / Failure / Ignored, recorded with exactly one Storage::recordOutcome() call (this is also what releases a HalfOpen probe slot — including when $callback throws). The outcome is timestamped when the callback completes. The original Admission and an opaque attempt ID are passed so a reclaimed probe cannot affect a newer generation or enter a newly reset Closed window.
  4. A Failure outcome with $fallback given returns the fallback's result; otherwise the original exception is rethrown. An Ignored outcome (isFailure() === false) is always rethrown as-is — it never triggers $fallback and never counts against the threshold.

Sizing the knobs

  • failureThreshold (Ratio::of(failures, window, within)) — a sliding ring buffer capped at window entries and pruned to the last within duration. Ratio::of(failures: 5, window: 10, within: seconds(60)) opens once 5 of the last (up to 10, within 60s) Closed calls failed.
  • cooldown — how long Open lasts before the first probe is allowed. Too short and probes hammer a still-recovering dependency; too long and recovery is delayed after the dependency is actually healthy again.
  • successThreshold — consecutive HalfOpen probe successes needed to close. 1 closes on the first successful probe; higher values demand sustained recovery before resuming full traffic.
  • probeLimit — probes admitted (leased) per HalfOpen generation (default 1). 1 is the safest default (single canary call); raising it to 3-5 improves recovery throughput at the cost of sending more traffic to a dependency that might still be unhealthy. This bounds admitted slots, not concurrent execution against the downstream — see the probeTimeout caveat below.
  • probeTimeout — maximum lifetime of an admitted HalfOpen probe lease; defaults to cooldown. Abandoned slots are reclaimed automatically. The lease is generation-wide, not per-probe: if probeTimeout is shorter than real downstream latency, an expired lease reclaims all probeLimit slots at once, and fresh probes can be admitted on top of ones that are still genuinely running — so real concurrent calls against the downstream can briefly exceed probeLimit. Size probeTimeout above expected downstream latency, or pair this package with rasuvaeff/bulkhead if you need a hard cap on concurrent downstream calls regardless of lease timing.
  • isFailure — required classifier. Filter to exceptions that actually indicate the downstream is unhealthy (network errors, 5xx responses).

How the state holds across workers/hosts

RedisStorage runs admit()/recordOutcome()/snapshot()/forceState() each as exactly one Lua script. Redis executes scripts to completion without interleaving another client's command, so the read-evaluate-transition sequence (check counters → decide → write new state) is atomic across every process racing on the same breaker — splitting that into separate INCR/GET/SET calls would reopen the exact check-then-act race the package exists to prevent. State lives in one Redis hash (state, openedAt, probe counters), one sorted set per breaker (the Closed sliding window, member = outcome, score = timestamp), and a set of opaque IDs for active probes. All keys share a Redis Cluster hash tag, so every multi-key script stays within one cluster slot.

ApcuStorage approximates that with an apcu_add-based lock around a read-transition-write of the whole entry (APCu has no server-side scripting). Locks carry integer owner tokens and release through CAS, so a stale owner cannot release a replacement lock after its lease expired. The lock is a lease (lockTtlSeconds, default 1s), not a Redis-grade mutex: a critical section that outlives its lease could otherwise commit over a worker that already took over, so the commit verifies ownership first and raises StorageFailure instead. That narrows the window rather than closing it — APCu cannot make the ownership check and the entry write one atomic step, so raise lockTtlSeconds if your workers can stall for longer. A rejected apcu_store() (shared memory exhausted) raises StorageFailure too: a transition that was not written is never reported as committed. APCu only coordinates workers on the same host — use RedisStorage for a pool spread across hosts.

Security

  • name is validated against /^[A-Za-z0-9_.:-]+\z/ and becomes part of the Redis/APCu key — untrusted names are rejected, not interpolated blindly.
  • Values flow into the Lua scripts as bound ARGV/KEYS, never string-concatenated.
  • Every CircuitBreaker::call() carries an opaque attempt ID. Redis records active probe IDs and atomically ignores outcomes from reclaimed generations.
  • The package opens no network connections itself; you supply the Redis client.

Caveats

  • isFailure is required. Pass a classifier that inspects the exception type/status so caller bugs do not open the circuit.
  • Storage failures are not downstream failures. An exception from recordOutcome() is wrapped in StorageFailure, is never passed through isFailure, and does not trigger fallback; the wrapper exposes the failed operation and the original exception via getPrevious(). When the callback had already thrown and the storage failed while recording that very outcome, the StorageFailure outranks the downstream exception — but the downstream exception stays reachable via the wrapper's public downstreamOutcome property, so it can still be logged or reacted to. See examples/07-storage-outage.php for a logging/degradation pattern.
  • Clocks and time mode. RedisStorage uses Redis server time by default for cooldown and probe leases. Pass useServerTime: false only for deterministic tests; that mode compares the caller's clock and requires synchronization. APCu always uses the caller clock, so hosts using it must run NTP. Probe fencing does not depend on clock synchronization: Redis validates the opaque attempt ID against the active probe generation in both time modes. One caveat even with useServerTime: true: canCall() and the retryAfter on CircuitOpenException compare the caller's clock against an openedAt stamped by Redis, so under clock skew they can disagree with what admit() (which decides entirely on server time) would do, by up to the skew amount. retryAfter never goes into the past — it is clamped to now — but treat it as advisory, not exact, when app and Redis clocks may drift. A fresh, never-opened breaker reports openedAt as epoch 0 in snapshots/metrics (all backends).
  • snapshot() never mutates. It does not apply the lazy OpenHalfOpen cooldown transition and does not prune the sliding window — only call() (via admit()/recordOutcome()) does. state()/metrics() therefore reflect the state as of the last real call, not a live re-evaluation against the current time.
  • InMemoryStorage is single-process only — it does not coordinate across the FPM pool. Use it for tests and CLI tools.
  • ApcuStorage only coordinates workers on the same machine. A pool spread across hosts needs RedisStorage. If lock contention exceeds the configured spin budget, the operation throws and CircuitBreaker exposes a StorageFailure; state transitions are never silently dropped.
  • This package does not retry or limit concurrency. Compose with rasuvaeff/retry (retries transient errors inside one call) and rasuvaeff/bulkhead (limits concurrent calls) — see examples/03-with-retry.php and examples/04-with-bulkhead.php.

Result classification and transitions

BreakerConfig::$classifyResult is optional and defaults every normal callback return to Outcome::Success. Configure it when an API reports downstream failure in a normal value; the original value is still returned and fallback is not invoked.

Pass a CircuitObserver and its paired error handler to CircuitBreaker to receive committed CircuitTransition events without polling metrics(). Storage::admit() returns AdmissionResult, and recordOutcome() returns OutcomeResult; both carry optional transition metadata.

Implementing Storage yourself: $admission, $admittedAt, and $attemptId are required on recordOutcome() and must be exactly what admit() returned and received. An outcome that is not an Admission::Probe must never move a HalfOpen breaker — it was admitted while the breaker was Closed and belongs to no probe generation.

Examples

See examples/ for runnable scripts. Examples are expected to execute without fatal errors and stay aligned with the documented public API.

Script Shows Needs server?
01-in-memory.php Minimal breaker: tripping on failureThreshold, fallback no
02-redis-cluster.php Cross-host state via RedisStorage + predis yes
03-with-retry.php Composition with rasuvaeff/retry no
04-with-bulkhead.php Composition with rasuvaeff/bulkhead no
05-prometheus-metrics.php Exporting Metrics in Prometheus exposition format no
06-apcu.php Single-host cross-process state via ApcuStorage no (needs ext-apcu)
07-storage-outage.php Handling StorageFailure separately from downstream failures no

Development

No PHP/Composer on the host — run in Docker via the composer:2 image:

docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix
docker run --rm -v "$PWD":/app -w /app composer:2 composer test

Integration tests need a Redis server (self-skip unless REDIS_HOST is set), ext-apcu (self-skip via ApcuStorage::isAvailable()) and ext-redis (self-skip via extension_loaded('redis')); the base composer:2 image has none of them, so run the suite in an image carrying apcu, pcntl and redis (plus apc.enable_cli=1):

docker run -d --name cb-redis -p 6379:6379 redis:7-alpine
docker run --rm --network host -v "$PWD":/app -w /app -e REDIS_HOST=127.0.0.1 \
  <php-image-with-apcu-pcntl-redis> vendor/bin/testo --suite=Integration
docker rm -f cb-redis

License

BSD-3-Clause