Search by

rasuvaeff / resilience

rasuvaeff

Resilience pipeline for PHP: composes retry, circuit breaker, and bulkhead in the correct order with the exception glue built in

Package info

github.com/rasuvaeff/resilience

pkg:composer/rasuvaeff/resilience

Statistics

Installs: 784

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-10-15 18:36 UTC

This package is auto-updated.

Last update: 2026-10-15 18:38:10 UTC


README

Latest Stable Version Total Downloads Build Static analysis Psalm Level License

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 StorageFailure counts only when the store failed on admit or on the rejected-path snapshot. One raised by recordOutcome means the callback already ran and its result was not recorded: not a rejection, because re-queueing it could execute a non-idempotent call twice.
  • RetryExhausted is 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 for CircuitOpenException (measured against the clock you pass, system clock by default) and the slot lease, an upper bound, for BulkheadFullException. It is null for StorageFailure.

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 StorageFailure contract).
  • With the breaker wrapping the bulkhead, a BulkheadFullException passes through the breaker's isFailure classifier. 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.