PHPackages                             rasuvaeff/circuit-breaker - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Caching](/categories/caching)
4. /
5. rasuvaeff/circuit-breaker

ActiveLibrary[Caching](/categories/caching)

rasuvaeff/circuit-breaker
=========================

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

v1.0.0(yesterday)00BSD-3-ClausePHP 8.3 - 8.5

Since Jul 22Compare

[ Source](https://github.com/rasuvaeff/circuit-breaker)[ Packagist](https://packagist.org/packages/rasuvaeff/circuit-breaker)[ Docs](https://github.com/rasuvaeff/circuit-breaker)[ RSS](/packages/rasuvaeff-circuit-breaker/feed)WikiDiscussions Synced today

READMEChangelogDependencies (13)Versions (2)Used By (0)

rasuvaeff/circuit-breaker
=========================

[](#rasuvaeffcircuit-breaker)

[![Latest Stable Version](https://camo.githubusercontent.com/bff3413fd1572a8125b70203294e7efb92599d35e50d87117ec13324718e350b/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f636972637569742d627265616b65722f76)](https://packagist.org/packages/rasuvaeff/circuit-breaker)[![Total Downloads](https://camo.githubusercontent.com/449af71d592bda49b2c70ef5ca138177e6201a60c8bb041086545d0fb0e6cf79/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f636972637569742d627265616b65722f646f776e6c6f616473)](https://packagist.org/packages/rasuvaeff/circuit-breaker)[![Build](https://github.com/rasuvaeff/circuit-breaker/actions/workflows/build.yml/badge.svg)](https://github.com/rasuvaeff/circuit-breaker/actions/workflows/build.yml)[![Static analysis](https://github.com/rasuvaeff/circuit-breaker/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/rasuvaeff/circuit-breaker/actions/workflows/static-analysis.yml)[![Psalm level](https://camo.githubusercontent.com/68f7f31799f2b93c710b14ba3877072e7fe07ec9d7cee3fdf67e14beab3e1b6f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7073616c6d2d6c6576656c5f312d626c75652e737667)](https://github.com/rasuvaeff/circuit-breaker/actions/workflows/static-analysis.yml)[![PHP](https://camo.githubusercontent.com/dd84069f92e83735ee89bea0e110c0bcaab7fb83f3596b206bda9f037c005fcd/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646570656e64656e63792d762f7261737576616566662f636972637569742d627265616b65722f706870)](https://packagist.org/packages/rasuvaeff/circuit-breaker)[![License](https://camo.githubusercontent.com/6cb285b57819f8de0acfb34923298f4f569f962544e8fe35331da2d163f4e485/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4253442d2d332d2d436c617573652d626c75652e737667)](LICENSE.md)[Русская версия](README.ru.md)

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](llms.txt) contains a compact API reference you can share with the model.

Requirements
------------

[](#requirements)

- PHP 8.3+
- [`rasuvaeff/duration`](https://github.com/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`](https://github.com/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
------------

[](#installation)

```
composer require rasuvaeff/circuit-breaker

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

Usage
-----

[](#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

[](#public-api)

TypeDescription`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

[](#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

[](#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`** — concurrent probes admitted in `HalfOpen` (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.
- **`probeTimeout`** — maximum lifetime of an admitted HalfOpen probe lease; defaults to `cooldown`. 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

[](#how-the-state-holds-across-workershosts)

`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
--------

[](#security)

- `name` is 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
-------

[](#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()`. 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.
- **`snapshot()` never mutates.** It does not apply the lazy `Open` → `HalfOpen` 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`](https://github.com/rasuvaeff/retry) (retries transient errors *inside* one call) and [`rasuvaeff/bulkhead`](https://github.com/rasuvaeff/bulkhead) (limits concurrent calls) — see `examples/03-with-retry.php` and `examples/04-with-bulkhead.php`.

Result classification and transitions
-------------------------------------

[](#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
--------

[](#examples)

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

ScriptShowsNeeds server?`01-in-memory.php`Minimal breaker: tripping on `failureThreshold`, `fallback`no`02-redis-cluster.php`Cross-host state via `RedisStorage` + predisyes`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 formatno`06-apcu.php`Single-host cross-process state via `ApcuStorage`no (needs `ext-apcu`)`07-storage-outage.php`Handling `StorageFailure` separately from downstream failuresnoDevelopment
-----------

[](#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 \
   vendor/bin/testo --suite=Integration
docker rm -f cb-redis
```

License
-------

[](#license)

[BSD-3-Clause](LICENSE.md)

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity50

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

1d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b0812d5572a7041dfe36e222d295b2e6dc55833a605350fcde58a51a5965ed30?d=identicon)[rasuvaeff](/maintainers/rasuvaeff)

---

Tags

redisapcucircuit breakerresilience

###  Code Quality

Static AnalysisPsalm, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/rasuvaeff-circuit-breaker/health.svg)

```
[![Health](https://phpackages.com/badges/rasuvaeff-circuit-breaker/health.svg)](https://phpackages.com/packages/rasuvaeff-circuit-breaker)
```

###  Alternatives

[symfony/symfony

The Symfony PHP framework

31.4k87.2M2.2k](/packages/symfony-symfony)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k16](/packages/tempest-framework)[ecotone/ecotone

Enterprise architecture layer for Laravel and Symfony — CQRS, Event Sourcing, Durable Workflows (Sagas, Orchestrators), Projections, and Outbox messaging via PHP attributes.

564576.7k54](/packages/ecotone-ecotone)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

85036.3k](/packages/flow-php-flow)[robinn/phpcacheadmin

A web dashboard for your favorite caching system.

4473.9k1](/packages/robinn-phpcacheadmin)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
