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-testo: ^0.6
- rasuvaeff/rector-named-literals: ^1.0
- 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-08-27 16:25:17 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. 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/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, 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
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— probes admitted (leased) perHalfOpengeneration (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. This bounds admitted slots, not concurrent execution against the downstream — see theprobeTimeoutcaveat below.probeTimeout— maximum lifetime of an admitted HalfOpen probe lease; defaults tocooldown. Abandoned slots are reclaimed automatically. The lease is generation-wide, not per-probe: ifprobeTimeoutis shorter than real downstream latency, an expired lease reclaims allprobeLimitslots 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 exceedprobeLimit. SizeprobeTimeoutabove expected downstream latency, or pair this package withrasuvaeff/bulkheadif 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
nameis 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
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(). When the callback had already thrown and the storage failed while recording that very outcome, theStorageFailureoutranks the downstream exception — but the downstream exception stays reachable via the wrapper's publicdownstreamOutcomeproperty, so it can still be logged or reacted to. 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. One caveat even withuseServerTime: true:canCall()and theretryAfteronCircuitOpenExceptioncompare the caller's clock against anopenedAtstamped by Redis, so under clock skew they can disagree with whatadmit()(which decides entirely on server time) would do, by up to the skew amount.retryAfternever goes into the past — it is clamped tonow— but treat it as advisory, not exact, when app and Redis clocks may drift. A fresh, never-opened breaker reportsopenedAtas epoch 0 in snapshots/metrics (all backends). 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