matheus85 / duat
Unified resilience patterns for modern PHP: retry, circuit breaker, timeout, bulkhead, rate limiter and fallback. Zero required dependencies.
Requires
- php: >=8.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
- predis/predis: ^3.5
- psr/event-dispatcher: ^1.0
- psr/simple-cache: ^3.0
Suggests
- ext-redis: Recommended production state store with atomic operations
- predis/predis: Pure PHP alternative to ext-redis, also supported by RedisStore
- psr/event-dispatcher: To receive resilience events (circuit opened, retry attempted...)
- psr/simple-cache: To use any PSR-16 cache as shared state store
README
Unified resilience patterns for modern PHP: retry, circuit breaker, timeout, bulkhead, rate limiter and fallback behind one fluent API or PHP 8 attributes. Zero required dependencies, no framework coupling, no HTTP client coupling. Duat wraps callables, nothing else.
use Duat\Duat; $user = Duat::for('users-api') ->retry(maxAttempts: 3) ->fallback(fn () => ['name' => 'cached user', 'source' => 'fallback']) ->call(fn () => json_decode(file_get_contents('https://jsonplaceholder.typicode.com/users/1'), true));
In Egyptian mythology the Duat is the underworld the sun crosses every night, fighting through the dark to be reborn at dawn. Failure, crossing, recovery, in an endless cycle. That is the exact life of a circuit breaker (closed, open, half-open, closed again), so the name stuck.
Why another library?
PHP never got its Resilience4j. Java has it, .NET has Polly, Node has
cockatiel. Here the landscape is fragmented: Ganesha does circuit breaking
well, PrestaShop's circuit breaker is tied to specific HTTP clients, and
retry logic usually ends up as a hand-rolled loop with sleep() calls
spread across the codebase. I wanted the whole toolbox in one place,
framework agnostic and fully testable without touching a real clock, so I
built it.
| Duat | Ganesha | PrestaShop/circuit-breaker | |
|---|---|---|---|
| Retry with backoff | yes | no | no |
| Circuit breaker | yes | yes | yes |
| Timeout (deadline) | yes | no | per HTTP client |
| Fallback | yes | no | yes |
| Bulkhead | yes | no | no |
| Rate limiter | yes | no | no |
| PHP 8 attributes | yes | no | no |
| Required dependencies | none | none | HTTP client |
If you come from Java think Resilience4j, if you come from .NET think Polly. That toolbox, in PHP: every pattern above is shipped and tested.
Install
composer require matheus85/duat
PHP 8.3 or newer, nothing else required. Optional: any PSR-16 cache or Redis for shared state, any PSR-14 dispatcher for events.
The pipeline
Policies compose like middleware around your callable. Method order defines
the nesting, outermost first, and fallback() always sits at the very
outside no matter where you declare it:
use Duat\Backoff\Backoff; use Duat\Duat; use Duat\Store\RedisStore; $result = Duat::for('sefaz-nfe') ->retry(maxAttempts: 3, backoff: Backoff::exponential(baseMs: 200, capMs: 10_000)) ->circuitBreaker(failureRateThreshold: 0.5, minimumCalls: 10) ->timeout(seconds: 5.0) ->fallback(fn (Throwable $e) => ReceiptStatus::unavailable()) ->store(new RedisStore($redis)) ->call(fn () => $sefaz->queryReceipt($key));
Builders are immutable: every method returns a new instance, so you can configure a chain once, inject it anywhere and reuse it freely.
Order matters
retry()->circuitBreaker() retries around the breaker. When the circuit
opens, the rejection stops the retry loop immediately: Duat never retries
CircuitOpenException, because hammering an open circuit defeats its
purpose. circuitBreaker()->retry() puts the whole retry burst inside a
single breaker call instead, so one exhausted retry counts as one failure
in the window. Both arrangements are legitimate, pick one consciously.
Attributes
Prefer declaring resilience where the method lives? Annotate it and wrap the instance:
use Duat\Attributes\CircuitBreaker; use Duat\Attributes\Fallback; use Duat\Attributes\Retry; use Duat\Proxy\ProxyFactory; final class PaymentGateway { #[Retry(maxAttempts: 3, backoffMs: 200)] #[CircuitBreaker(failureRateThreshold: 0.5, cooldownSeconds: 30)] #[Fallback(method: 'queueForLater')] public function charge(Order $order): Receipt { // talk to the acquirer } public function queueForLater(Order $order, Throwable $e): Receipt { // same arguments, plus the exception } } $factory = new ProxyFactory(store: new RedisStore($redis)); $gateway = $factory->wrap(new PaymentGateway()); $gateway->charge($order);
Attribute order defines the pipeline order and #[Fallback] is always the
outermost layer, exactly like the fluent builder. #[Timeout],
#[Bulkhead] and #[RateLimiter] work the same way. Attribute arguments
only accept constant expressions, so backoff is configured with scalars
(backoffMs, capMs, jitter and backoff: BackoffType::Linear). Shared
state is keyed by Class::method; keep a single factory per process so
every proxy shares one store.
The proxy trade-off, upfront
wrap() returns a composition proxy built on __call, and that has two
consequences you should know before choosing attributes:
- The proxy is not an instanceof of your class, so it cannot be passed where the original class or one of its interfaces is type-hinted.
- Static analysis and IDE autocompletion lose the method signatures: calls
go through a magic method and return
mixed.
When either of those matters, use the fluent API: same engine, full
typing. A generated-proxy mode (a real subclass) may come later if the
__call approach proves limiting in practice.
Policies
Retry
->retry( maxAttempts: 4, backoff: Backoff::exponential(baseMs: 200, capMs: 10_000, jitter: true), retryOn: [TransportException::class], abortOn: [AuthException::class], onRetry: fn (Throwable $e, Context $ctx) => $log->warning("attempt {$ctx->attempt} failed"), )
Backoffs: Backoff::constant(), Backoff::linear() and
Backoff::exponential() with cap and full jitter (the AWS flavor: the
delay is drawn uniformly from zero to the doubling base). The default is
exponential, 200ms base, 10s cap, jitter on.
abortOn wins over retryOn. Exhaustion throws RetryExhaustedException
carrying the last failure as previous. And when composed with
timeout(), retry gives up as soon as the next wait would not fit the
remaining budget, rethrowing the real failure instead of sleeping past the
deadline.
Circuit breaker
->circuitBreaker( failureRateThreshold: 0.5, // opens at 50% failures... minimumCalls: 10, // ...once the window holds 10 calls windowSeconds: 60, // time-based sliding window cooldownSeconds: 30, // time in OPEN before probing halfOpenMaxCalls: 1, // probes allowed in HALF_OPEN recordOn: [Throwable::class], )
The window is a set of per-second buckets in the state store, updated with
atomic increments, so multiple PHP-FPM workers share one view of the
resource. Each resource name gets its own circuit. Transitions emit events
(see below) and rejections throw CircuitOpenException.
Timeout, honestly
->timeout(seconds: 5.0) // report late successes ->timeout(seconds: 5.0, throwOnLateSuccess: true) // punish them
What timeout() does not do: synchronous PHP cannot interrupt a
blocking call, and Duat refuses to pretend otherwise (no pcntl tricks).
The policy registers a deadline in the context, inner layers read it
through Context::remainingBudget(), and when the callable comes back late
you either get the result plus a DeadlineExceeded event (default) or a
TimeoutExceededException (strict mode).
Real cancellation belongs in the client. Set your cURL, Guzzle or stream timeouts, and feed them from the context if you want a single budget across retries.
Fallback
->fallback( fn (Throwable $e, Context $ctx) => Status::degraded(), on: [CircuitOpenException::class, RetryExhaustedException::class], )
Always the outermost layer, so it catches failures from every policy and
from the callable itself. on filters which exceptions trigger it; the
rest propagate untouched.
Bulkhead
->bulkhead(maxConcurrent: 25, leaseSeconds: 60)
Caps how many calls run at the same time against the resource, across all
workers sharing the store. When full it throws BulkheadFullException
immediately: no queue, because parking a synchronous PHP worker to wait
for a slot just moves the pile-up somewhere worse.
The active-call counter takes a safety lease when created, so slots leaked
by a process that died mid-call heal themselves after leaseSeconds. Keep
the lease comfortably above your slowest expected call.
Rate limiter
->rateLimiter(maxCalls: 100, perSeconds: 60)
Fixed window: one shared counter per window, so the cap holds across every
worker on the store. Exceeding it throws RateLimitExceededException
carrying retryAfterSeconds until the next window opens. Rejected
attempts count too.
One honest caveat: fixed windows allow a burst of up to twice the limit right around a window boundary. Cheap and predictable; if that ever hurts your use case, open an issue and a sliding variant gets prioritized.
Shared state
Circuit breaker, bulkhead and rate limiter state has to live somewhere. Pick a store:
| Store | Backend | Atomicity |
|---|---|---|
InMemoryStore |
process array | single process only, default |
Psr16Store |
any PSR-16 cache | read-modify-write, benign races documented |
RedisStore |
ext-redis or Predis | atomic (Lua increments, SET NX leases) |
The default InMemoryStore does not cross PHP-FPM workers: each worker
would run its own circuit, count its own bulkhead slots and enforce its own
rate limit. Fine for CLI tools, queue workers and tests, but production web
workloads want ->store(new RedisStore($client)).
Events
Pass any PSR-14 dispatcher with ->events($dispatcher) and Duat emits
readonly event objects: RetryAttempted, CircuitOpened,
CircuitHalfOpened, CircuitClosed, CallRejected, DeadlineExceeded
and FallbackExecuted. CallRejected carries a RejectionReason enum
telling whether the circuit was open, the bulkhead was full or the rate
limit was hit. No dispatcher, no events, no overhead. The proxy factory
takes the same dispatcher in its constructor.
Watch it live
examples/flaky-api ships a dockerized API that alternates health and
failure every 15 seconds, plus a demo script that crosses it with retry,
circuit breaker, timeout and fallback while printing every event:
cd examples/flaky-api
docker compose up -d
php demo.php
You will see retries, the circuit opening at the failure threshold, fast rejections with fallback answers, probes during cooldown and the circuit closing once the API recovers.
examples/attributes tells the same story through an annotated payment
gateway, failures simulated in-process, no docker required.
Testing your own code
Time and randomness are injectable everywhere. Implement the two tiny
interfaces Duat\Contract\Clock and Duat\Contract\Randomizer, pass them
with ->clock() and ->randomizer(), and your resilience tests run in
milliseconds with zero real sleeps. Duat's own unit suite works exactly
like that and finishes in a fraction of a second.
Overhead
Resilience is not free, but it is cheap. On the development machine
(PHP 8.4, Windows), a successful call through the full chain (retry,
circuit breaker, timeout and fallback over the in-memory store) costs about
4 microseconds on top of the bare callable, and the attribute proxy sits
around 3. Failure paths cost more by design: they hit the window, sleep
through backoffs and walk the fallback. Run php benchmarks/overhead.php
for numbers on your hardware.
Roadmap
Next up is a first-class Laravel bridge as a separate package
(matheus85/duat-laravel): service provider, cache-backed stores, HTTP
client macro, native events.
License
MIT.