rasuvaeff / resilience
Resilience pipeline for PHP: composes retry, circuit breaker, and bulkhead in the correct order with the exception glue built in
Requires
- php: 8.3 - 8.5
- psr/clock: ^1.0
- rasuvaeff/bulkhead: ^1.4
- rasuvaeff/circuit-breaker: ^1.3
- rasuvaeff/context: ^0.3
- rasuvaeff/duration: ^1.1
- rasuvaeff/retry: ^1.6.2
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/doc-exec: ^0.1
- 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
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 18:38:10 UTC
README
Resilience pipeline for PHP: composes rasuvaeff/retry,
rasuvaeff/circuit-breaker, and
rasuvaeff/bulkhead in the correct
order with the exception glue built in — the PHP analogue of Polly's
ResiliencePipeline / resilience4j's Decorators.
Using an AI coding assistant? Point it at llms.txt — a compact, self-contained API reference.
Why
The three leaf packages compose with plain closures — but composing them correctly requires glue that is easy to get wrong:
| Rule | Why |
|---|---|
Retry must stop on CircuitOpenException |
the circuit is open; more attempts are pointless and hostile to the downstream |
Retry must stop on BulkheadFullException (by default) |
saturation is not a transient error; blind re-attempts amplify overload |
Retry must stop on the breaker's StorageFailure |
an infrastructure outage is not a downstream verdict |
| Bulkhead innermost | a slot is held only while the callback runs — never during retry sleeps |
| Breaker outside the bulkhead | an open circuit rejects before a slot is even requested |
| Breaker inside vs outside retry | two legitimate orders with different semantics — see below |
Pipeline fixes the nesting order and appends the glue automatically,
without modifying the objects you pass in.
Requirements
- PHP 8.3+
rasuvaeff/retry^1.3,rasuvaeff/circuit-breaker^1.3,rasuvaeff/bulkhead^1.3,rasuvaeff/duration^1.1 (installed automatically)
Installation
composer require rasuvaeff/resilience
Usage
The block below is executable — composer build runs it against the real
API via rasuvaeff/doc-exec, so the
// => values cannot rot. It uses the in-memory backends; in production you
point the bulkhead and the breaker at Redis/APCu stores — the pipeline code
does not change.
use Rasuvaeff\Bulkhead\InMemoryBulkheadStore; use Rasuvaeff\Bulkhead\SharedBulkhead; use Rasuvaeff\CircuitBreaker\BreakerConfig; use Rasuvaeff\CircuitBreaker\CircuitBreaker; use Rasuvaeff\CircuitBreaker\CircuitOpenException; use Rasuvaeff\CircuitBreaker\Clock\SystemClock; use Rasuvaeff\CircuitBreaker\InMemoryStorage; use Rasuvaeff\CircuitBreaker\Ratio; use Rasuvaeff\Duration\Duration; use Rasuvaeff\Resilience\Pipeline; use Rasuvaeff\Retry\Retry; // Configure the leaves as usual - the pipeline takes them ready-made and // knows nothing about stores, backends, or policies. $pipeline = Pipeline::for('billing') ->bulkhead(new SharedBulkhead( name: 'billing', maxConcurrent: 2, store: new InMemoryBulkheadStore(), lease: Duration::seconds(5), maxWait: Duration::zero(), )) ->retry( Retry::new() ->maxAttempts(maxAttempts: 3) ->withImmediate(), ) ->circuitBreaker(new CircuitBreaker( config: new BreakerConfig( name: 'billing', failureThreshold: Ratio::of(failures: 5, window: 10, within: Duration::seconds(60)), cooldown: Duration::seconds(30), successThreshold: 1, isFailure: static fn(\Throwable $e): bool => $e instanceof \RuntimeException, ), storage: new InMemoryStorage(), clock: new SystemClock(), )) ->build(); // immutable, reusable // A transient failure is retried to success: $attempt = 0; $pipeline->call(function () use (&$attempt): string { if (++$attempt < 2) { throw new \RuntimeException('transient error'); } return 'succeeded'; }); // => "succeeded" $attempt; // => 2 // Optional fallback at the outermost level receives the terminal exception: $pipeline->call( callback: static fn(): string => 'primary', fallback: static fn(\Throwable $e): string => $e instanceof CircuitOpenException ? 'degraded' : 'unexpected', ); // => "primary"
Every layer is optional, in any combination; an empty pipeline is a plain call (convenient for conditional assembly).
Nesting order
The default is retry(circuitBreaker(bulkhead(callback))):
- bulkhead innermost — a slot is occupied only while the callback actually runs, re-requested per attempt; retry sleeps consume zero concurrency budget;
- breaker around the bulkhead — an open circuit rejects before a slot is requested;
- retry outermost — with the glue predicates appended.
Breaker inside vs outside retry
BreakerInsideRetry (default) |
->breakerOutsideRetry() |
|
|---|---|---|
| One breaker outcome per | attempt | logical operation (whole retry loop) |
| Open circuit mid-loop | cuts the remaining attempts instantly | cannot cut the loop short |
| Transient blip fixed by a retry | still counts toward the failure ratio | never reaches the failure ratio |
| Choose when | the breaker should see real per-call health | the breaker should judge operations, not attempts |
isFailure receives |
the callback's own exception (and, breaker-wrapping-bulkhead, BulkheadFullException) |
what the retry loop throws — RetryExhausted on exhaustion (last downstream exception in its lastException), never the callback's exception directly |
Exception glue
build() appends stopIf predicates to a copy of your retry builder
(Retry is immutable — the instance you pass is never modified, and your
own stopIf/retryIf rules are preserved):
CircuitOpenException→ stop, always;StorageFailure(breaker's storage outage) → stop, always;BulkheadFullException→ stop by default; opt back in with->retryOnBulkheadFull()when a slot is expected to free within the backoff window.
All terminal exceptions surface unchanged — RetryExhausted,
CircuitOpenException, BulkheadFullException, StorageFailure, or the
callback's own exception. The optional fallback receives whichever of
them ended the call.
Rejections and domain exceptions
A call can end without the callback ever running: the circuit is open, every
slot is taken, or the breaker's store failed before the callback ran.
Rejection::is() recognises exactly these three, and Rejection::retryAfter() turns the leaf's hint into
a relative Duration for a Retry-After header or a re-queue delay.
onRejected() maps them to your domain exception once per pipeline, instead
of a try/catch around every call site:
use Rasuvaeff\Bulkhead\BulkheadFullException; use Rasuvaeff\Bulkhead\InMemoryBulkheadStore; use Rasuvaeff\Bulkhead\SharedBulkhead; use Rasuvaeff\Duration\Duration; use Rasuvaeff\Resilience\Pipeline; use Rasuvaeff\Resilience\Rejection; final class BillingUnavailable extends \RuntimeException {} $store = new InMemoryBulkheadStore(); $store->tryAcquire('billing', 1, Duration::seconds(5)); // the only slot is busy $pipeline = Pipeline::for('billing') ->bulkhead(new SharedBulkhead( name: 'billing', maxConcurrent: 1, store: $store, lease: Duration::seconds(5), maxWait: Duration::zero(), )) ->onRejected(static fn(\Throwable $e): \Throwable => new BillingUnavailable('billing is busy', previous: $e)) ->build(); try { $pipeline->call(static fn(): string => 'charged'); } catch (BillingUnavailable $e) { $e->getMessage(); // => "billing is busy" Rejection::is($e->getPrevious()); // => true Rejection::retryAfter($e->getPrevious())?->toMillis(); // => 5000 } Rejection::is(new \RuntimeException('HTTP 500')); // => false
- The mapper runs at the outermost level, before
fallback; the fallback receives the mapped exception. Any other exception passes through untouched. - A
StorageFailurecounts only when the store failed onadmitor on the rejected-pathsnapshot. One raised byrecordOutcomemeans the callback already ran and its result was not recorded: not a rejection, because re-queueing it could execute a non-idempotent call twice. RetryExhaustedis never a rejection, even when its last attempt was: an earlier attempt may have reached the downstream.retryAfter()is the time left until the circuit half-opens forCircuitOpenException(measured against the clock you pass, system clock by default) and the slot lease, an upper bound, forBulkheadFullException. It isnullforStorageFailure.
call() is generic: Psalm infers string above from the callback, no
@var at the call site.
Under a request budget (rasuvaeff/context)
Without a context every layer has its own notion of time — retry's
stopAfterMs, the bulkhead's maxWait, the breaker none — and none sees the
request's budget: a request with 300 ms left can wait 200 ms for a slot and
then sleep 400 ms of backoff. Pass the request's
rasuvaeff/context and every layer
obeys it:
$charge = $pipeline->call( static fn(Context $context): Charge => $stripe->charge($payload, $context), context: $requestContext, );
| Layer | Under a context |
|---|---|
| Start | assertActive() — a terminated context runs nothing |
| Retry | withContext(): checked before each attempt, backoff sleeps end with the budget, a context exception is never retried |
| Circuit breaker | A context terminated before the call starts never reaches the breaker (the start check, and retry's check before each attempt). One terminating mid-call — including a signal — reaches its isFailure: see ContextOutcome below |
| Bulkhead | A ContextAwareBulkhead (SharedBulkhead) waits for a slot no longer than the budget and ends with the context's exception, not BulkheadFullException; any other Bulkhead is called as before |
| Callback | Receives the context — pass it on to the transport (ContextClient, ContextClickHouseClient, context-db). Without context: it receives nothing, so internal functions keep working |
fallback |
Skipped when this context is cancelled (the caller is gone); called when it expired (a cached answer may still be worth returning) and for every other exception, as before |
Context exceptions are not rejections: Rejection::is() is false and
onRejected does not map them.
The breaker's classification stays yours. A context expiring inside the
call reaches the breaker's isFailure — and ContextException extends
RuntimeException, so a broad classifier counts a short caller budget as a
downstream failure and opens the circuit for everyone. Wrap your classifier:
use Rasuvaeff\Resilience\ContextOutcome; new BreakerConfig( // ... isFailure: ContextOutcome::ignoring(static fn(\Throwable $e): bool => $e instanceof ClientExceptionInterface), );
The pipeline never turns a context exception into a success instead: a half-open probe that ran out of the caller's budget would close the circuit.
Public API
| Type | Description |
|---|---|
Pipeline |
Builder: for(name), bulkhead(Bulkhead), retry(Retry), circuitBreaker(CircuitBreakerInterface), onRejected(Closure), breakerOutsideRetry(), retryOnBulkheadFull(), build() |
CompiledPipeline |
Immutable result: generic call(callable(): T, ?callable(Throwable): T $fallback, ?Context $context): T, name() |
ContextOutcome |
ignoring(callable $isFailure): Closure — a breaker classifier that never counts a context exception as a failure |
Rejection |
is(Throwable): bool, retryAfter(Throwable, ?ClockInterface): ?Duration |
PipelineOrder |
Enum: BreakerInsideRetry, BreakerOutsideRetry |
Security
- The pipeline adds no I/O, no storage, and no exception types of its own;
the security posture is that of the leaf packages (see their READMEs —
notably the bulkhead's fail-closed store semantics and the breaker's
StorageFailurecontract). - With the breaker wrapping the bulkhead, a
BulkheadFullExceptionpasses through the breaker'sisFailureclassifier. Classify it as not-a-failure (Ignored) unless you deliberately want local saturation to open the circuit against a healthy downstream:
isFailure: static fn(\Throwable $e): bool => !$e instanceof \Rasuvaeff\Bulkhead\BulkheadFullException && $e instanceof \Psr\Http\Client\ClientExceptionInterface,
Examples
See examples/ — runnable scripts using the in-memory
backends of the leaf packages (no server needed).
Development
make install
make build # validate → normalize → require-checker → cs → psalm → test
make release-check
License
BSD-3-Clause. See LICENSE.md.