rasuvaeff / circuit-breaker
Circuit breaker resilience primitive for PHP with pluggable in-memory, APCu, and Redis storage backends
Requires
- php: 8.3 - 8.5
- psr/clock: ^1.0
- rasuvaeff/duration: ^1.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- predis/predis: ^2.2 || ^3.0
- rasuvaeff/property-testing: ^2.6
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
Suggests
- ext-apcu: Required to use ApcuStorage for single-host distributed state
- ext-redis: Required to use RedisStorage via PhpRedisScriptRunner
- predis/predis: Required to use RedisStorage via PredisScriptRunner (^2.2, pure-PHP alternative to ext-redis)
This package is auto-updated.
Last update: 2026-07-22 17:25:09 UTC
README
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.
Requirements
- PHP 8.3+
rasuvaeff/durationfor the typed cooldown/window valuespsr/clock— inject anyClockInterface;Clock\SystemClock(production) andClock\FakeClock(tests) are included- For multi-host state (
RedisStorage): a reachable Redis server plus one Redis client —predis/predis^2.2 (pure-PHP,PredisScriptRunner) orext-redis(PhpRedisScriptRunner). Both are optional; install the one you use. ext-apcufor 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, Rejected — Storage::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, and the original exception |
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
Storage::admit()— atomically applies theOpen → HalfOpencooldown transition, then decidesAllowed(Closed) /Probe(HalfOpen, slot occupied) /Rejected(Open, orHalfOpenwith no free probe slot).Rejected→$callbackis never invoked.$fallback, if given, receives a freshCircuitOpenException; otherwise it is thrown. InOpen,retryAfteris the cooldown deadline; for a saturatedHalfOpen, it is a conservative deadline based onprobeTimeout.Allowed/Probe→$callbackruns. Its outcome is classified viaisFailure()intoSuccess/Failure/Ignored, recorded with exactly oneStorage::recordOutcome()call (this is also what releases aHalfOpenprobe slot — including when$callbackthrows). The outcome is timestamped when the callback completes. The originalAdmissionand an opaque attempt ID are passed so a reclaimed probe cannot affect a newer generation or enter a newly resetClosedwindow.- A
Failureoutcome with$fallbackgiven returns the fallback's result; otherwise the original exception is rethrown. AnIgnoredoutcome (isFailure() === false) is always rethrown as-is — it never triggers$fallbackand never counts against the threshold.
Sizing the knobs
failureThreshold(Ratio::of(failures, window, within)) — a sliding ring buffer capped atwindowentries and pruned to the lastwithinduration.Ratio::of(failures: 5, window: 10, within: seconds(60))opens once 5 of the last (up to 10, within 60s)Closedcalls failed.cooldown— how longOpenlasts 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— consecutiveHalfOpenprobe successes needed to close.1closes on the first successful probe; higher values demand sustained recovery before resuming full traffic.probeLimit— concurrent probes admitted inHalfOpen(default1).1is 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.probeTimeout— maximum lifetime of an admitted HalfOpen probe lease; defaults tocooldown. Abandoned slots are reclaimed automatically.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
nameis validated against/^[A-Za-z0-9_.:-]+$/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
isFailureis 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 inStorageFailure, is never passed throughisFailure, and does not triggerfallback; the wrapper exposes the failed operation and the original exception viagetPrevious(). Seeexamples/07-storage-outage.phpfor a logging/degradation pattern. - Clocks and time mode.
RedisStorageuses Redis server time by default for cooldown and probe leases. PassuseServerTime: falseonly 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. snapshot()never mutates. It does not apply the lazyOpen→HalfOpencooldown transition and does not prune the sliding window — onlycall()(viaadmit()/recordOutcome()) does.state()/metrics()therefore reflect the state as of the last real call, not a live re-evaluation against the current time.InMemoryStorageis single-process only — it does not coordinate across the FPM pool. Use it for tests and CLI tools.ApcuStorageonly coordinates workers on the same machine. A pool spread across hosts needsRedisStorage. If lock contention exceeds the configured spin budget, the operation throws andCircuitBreakerexposes aStorageFailure; state transitions are never silently dropped.- This package does not retry or limit concurrency. Compose with
rasuvaeff/retry(retries transient errors inside one call) andrasuvaeff/bulkhead(limits concurrent calls) — seeexamples/03-with-retry.phpandexamples/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