kissmint3395 / aegis
A PHP 8.2+ resilience library combining Retry, Circuit Breaker, Timeout, and more into a composable pipeline. Inspired by Resilience4j and .NET Polly.
Requires
- php: ^8.2
- psr/event-dispatcher: ^1.0
- psr/simple-cache: ^3.0
Requires (Dev)
- phpstan/extension-installer: ^1.3
- phpstan/phpstan: ^1.10
- phpstan/phpstan-strict-rules: ^1.5
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-06-28 08:01:34 UTC
README
A PHP 8.2+ resilience library that combines Retry, Circuit Breaker, Timeout, Rate Limiter, Bulkhead, Fallback, Cache, and Hedge into a single composable pipeline.
Inspired by Resilience4j and .NET Polly — the PHP ecosystem's missing equivalent.
$pipeline = ResiliencePipeline::builder() ->circuitBreaker('payment-api', failureThreshold: 5) ->retry(maxAttempts: 3, backoff: ExponentialBackoff::withJitter(Duration::milliseconds(100))) ->timeout(Duration::seconds(5)) ->fallback(fn(\Throwable $e) => CachedResponse::last()) ->build(); $result = $pipeline->execute(fn() => $httpClient->post('/charge', $payload));
Why Aegis?
PHP has several individual resilience libraries, but none that combine them into a composable pipeline:
| Library | Retry | CB | Timeout | Rate Limiter | Bulkhead | Fallback | Cache | Composable | PHP 8.2+ |
|---|---|---|---|---|---|---|---|---|---|
ackintosh/ganesha |
✗ | ✅ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✅ |
yohang/finite |
✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✅ |
cline/retry |
✅ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✅ |
| Aegis | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Requirements
- PHP 8.2+
psr/event-dispatcher^1.0psr/simple-cache^3.0 (optional, for persistent Circuit Breaker, Rate Limiter, and Bulkhead state)
Installation
composer require kissmint3395/aegis
Usage
Retry
Retry a failing operation with configurable backoff.
use Aegis\ResiliencePipeline; use Aegis\Backoff\ExponentialBackoff; use Aegis\Duration; $pipeline = ResiliencePipeline::builder() ->retry( maxAttempts: 3, backoff: ExponentialBackoff::withJitter(Duration::milliseconds(100)), retryOn: [\RuntimeException::class], ) ->build(); $result = $pipeline->execute(fn() => $api->fetch());
Backoff strategies:
use Aegis\Backoff\FixedBackoff; use Aegis\Backoff\ExponentialBackoff; // Fixed delay new FixedBackoff(Duration::milliseconds(200)) // Exponential: 100ms → 200ms → 400ms → ... ExponentialBackoff::create(Duration::milliseconds(100)) // Exponential with jitter: randomised between 50%–100% of each step ExponentialBackoff::withJitter(Duration::milliseconds(100), maxDelay: Duration::seconds(5))
Conditional retry:
->retry( retryIf: fn(\Throwable $e) => $e->getCode() >= 500, )
Circuit Breaker
Stop cascading failures by blocking calls when a service is unhealthy.
use Aegis\ResiliencePipeline; use Aegis\Duration; $pipeline = ResiliencePipeline::builder() ->circuitBreaker( name: 'inventory-api', failureThreshold: 5, // Open after 5 consecutive failures successThreshold: 2, // Close after 2 consecutive successes in HalfOpen resetAfter: Duration::seconds(30), ) ->build();
State transitions:
Closed ──(5 failures)──► Open ──(30s elapsed)──► HalfOpen
▲ │
└──────────(2 successes)───────────────────────────┘
(1 failure) ──► Open
Persistent state across requests (Redis, APCu, etc.):
use Aegis\Strategy\CircuitBreaker\Storage\Psr16Storage; $pipeline = ResiliencePipeline::builder() ->circuitBreaker('payment-api', storage: new Psr16Storage($redisCache)) ->build();
Ignore specific exceptions (e.g. validation errors should not trip the circuit):
->circuitBreaker('api', ignoreExceptions: [\InvalidArgumentException::class])
Adaptive Circuit Breaker
A sliding-window variant that opens based on the failure rate (%) over the last N calls, rather than a consecutive-failure count. Equivalent to Resilience4j's sliding-window circuit breaker.
$pipeline = ResiliencePipeline::builder() ->adaptiveCircuitBreaker( name: 'payment-api', windowSize: 20, // Evaluate the last 20 calls failureRateThreshold: 50.0, // Open if ≥ 50% fail minimumCalls: 5, // Don't open until at least 5 calls recorded successThreshold: 2, // Close after 2 successes in HalfOpen resetAfter: Duration::seconds(30), ) ->build();
Persistent state:
use Aegis\Strategy\CircuitBreaker\Storage\AdaptivePsr16Storage; ->adaptiveCircuitBreaker('api', storage: new AdaptivePsr16Storage($redisCache))
Timeout
Enforce a maximum execution duration.
->timeout(Duration::seconds(5))
Note: On Unix systems with the
pcntlextension, Aegis usesSIGALRMfor true preemptive interruption. On Windows and environments withoutpcntl, elapsed time is checked after execution — useful for limiting total retry budgets.
Rate Limiter (Fixed Window)
Limit the number of calls within a fixed time window.
use Aegis\ResiliencePipeline; use Aegis\Duration; $pipeline = ResiliencePipeline::builder() ->rateLimit( name: 'payment-api', limit: 100, // Max 100 calls per window window: Duration::seconds(60), ) ->build();
Persistent rate limiting across requests (Redis, APCu, etc.):
use Aegis\Strategy\RateLimiter\Storage\Psr16Storage; $pipeline = ResiliencePipeline::builder() ->rateLimit('payment-api', limit: 100, storage: new Psr16Storage($redisCache)) ->build();
Note: The default
InMemoryStorageis process-scoped. For rate limiting across PHP-FPM workers, usePsr16Storagebacked by Redis or APCu.
Rate Limiter (Sliding Window)
Eliminates the "boundary burst" problem of fixed-window rate limiting by tracking individual request timestamps.
$pipeline = ResiliencePipeline::builder() ->slidingRateLimit( name: 'payment-api', limit: 100, window: Duration::seconds(60), ) ->build();
Persistent state:
use Aegis\Strategy\RateLimiter\Storage\SlidingWindowPsr16Storage; ->slidingRateLimit('payment-api', limit: 100, storage: new SlidingWindowPsr16Storage($redisCache))
Note: Each entry stores a full timestamp array. For very high-volume endpoints, prefer a Redis ZSET-backed implementation.
Rate Limiter (Token Bucket)
Allows burst traffic up to a capacity while enforcing an average rate via continuous token refill. Tokens are added at refillRate per refillPeriod.
$pipeline = ResiliencePipeline::builder() ->tokenBucketRateLimit( name: 'payment-api', capacity: 20, // Max burst size refillRate: 10, // Tokens added per period refillPeriod: Duration::seconds(1), // Refill interval ) ->build();
Persistent state:
use Aegis\Strategy\RateLimiter\Storage\TokenBucketPsr16Storage; ->tokenBucketRateLimit('api', capacity: 20, storage: new TokenBucketPsr16Storage($redisCache))
Bulkhead
Limit the number of concurrent executions to prevent resource exhaustion.
use Aegis\ResiliencePipeline; $pipeline = ResiliencePipeline::builder() ->bulkhead( name: 'database', maxConcurrent: 10, // Allow at most 10 concurrent calls ) ->build();
Persistent concurrency tracking across requests (Redis, APCu, etc.):
use Aegis\Strategy\Bulkhead\Storage\Psr16Storage; $pipeline = ResiliencePipeline::builder() ->bulkhead('database', maxConcurrent: 10, storage: new Psr16Storage($redisCache)) ->build();
Note: The default
InMemoryStorageis process-scoped. For cross-worker concurrency limiting, usePsr16Storagebacked by Redis or APCu.
Fallback
Return a default value (or call an alternative callable) when all inner strategies fail.
$pipeline = ResiliencePipeline::builder() ->circuitBreaker('payment-api') ->retry(maxAttempts: 3) ->fallback(fn(\Throwable $e) => ['status' => 'unavailable']) ->build();
The fallback handler receives the original \Throwable. If the handler itself throws, a FallbackNotAvailableException is raised wrapping both the original and fallback exceptions.
use Aegis\Exception\FallbackNotAvailableException; try { $result = $pipeline->execute(fn() => $api->fetch()); } catch (FallbackNotAvailableException $e) { $original = $e->getOriginalException(); }
Cache
Cache successful results and serve them on subsequent calls. Optionally serve stale entries when the downstream call fails.
use Aegis\Strategy\Cache\Storage\CacheInMemoryStorage; $storage = new CacheInMemoryStorage(); // or CachePsr16Storage for persistence $pipeline = ResiliencePipeline::builder() ->cache( name: 'product-catalog', ttl: Duration::minutes(5), staleOnFailure: true, // Serve expired cache when downstream throws storage: $storage, ) ->build();
With staleOnFailure: true, if the downstream call throws and a (possibly expired) cache entry exists, the stale value is returned instead of propagating the exception.
Hedge (experimental)
Fire additional "hedge" requests after a configurable delay and return the first successful result. Reduces tail latency for operations where some replicas respond faster than others.
$pipeline = ResiliencePipeline::builder() ->hedge( delay: Duration::milliseconds(200), // Fire hedge after 200ms maxHedges: 1, ) ->build();
Note: True latency-based hedging requires callables that cooperatively yield via
Fiber::suspend()(e.g. ReactPHP / Swoole). For synchronous callables, hedges fire immediately after each failure.
Composing strategies
Strategies wrap each other in the order they are added (first = outermost). The recommended order is: Bulkhead → Rate Limiter → Timeout → Circuit Breaker → Retry → Fallback.
$pipeline = ResiliencePipeline::builder() ->bulkhead('svc', maxConcurrent: 10) // 1. Concurrency gate ->rateLimit('svc', limit: 100) // 2. Rate gate ->timeout(Duration::seconds(10)) // 3. Total time budget ->circuitBreaker('svc', failureThreshold: 5) // 4. Block if unhealthy ->retry(maxAttempts: 3) // 5. Retry transient failures ->fallback(fn() => $defaultValue) // 6. Last-resort fallback ->build();
PSR-14 Events
Observe what happens inside the pipeline by wiring up a PSR-14 event dispatcher.
use Aegis\Event\RetryAttempted; use Aegis\Event\CircuitOpened; use Aegis\Event\RateLimitExceeded; use Aegis\Event\TokenBucketExhausted; $pipeline = ResiliencePipeline::builder() ->withEventDispatcher($dispatcher) ->circuitBreaker('api') ->retry(maxAttempts: 3) ->build(); // Note: listener registration API (listen/addListener/subscribeTo) depends on your PSR-14 implementation. $dispatcher->listen(RetryAttempted::class, function (RetryAttempted $e) use ($logger): void { $logger->warning('Retry attempt', [ 'attempt' => $e->attempt, 'max' => $e->maxAttempts, 'delay_ms' => $e->delayMs, 'error' => $e->cause->getMessage(), ]); });
| Event | Fired when |
|---|---|
RetryAttempted |
A retry is about to be delayed and re-attempted |
CircuitOpened |
Circuit transitions Closed → Open |
CircuitClosed |
Circuit transitions HalfOpen → Closed |
CircuitHalfOpened |
Circuit transitions Open → HalfOpen |
RateLimitExceeded |
A call is rejected because the rate limit is reached |
BulkheadRejected |
A call is rejected because the bulkhead is full |
TokenBucketExhausted |
A call is rejected because the token bucket is empty |
Custom strategies
Implement StrategyInterface to plug in your own logic.
use Aegis\Contract\StrategyInterface; final class LoggingStrategy implements StrategyInterface { public function __construct(private readonly LoggerInterface $logger) {} public function execute(callable $next): mixed { $start = microtime(true); try { $result = $next(); $this->logger->info('OK', ['ms' => (int)((microtime(true) - $start) * 1000)]); return $result; } catch (\Throwable $e) { $this->logger->error($e->getMessage()); throw $e; } } } $pipeline = ResiliencePipeline::builder() ->addStrategy(new LoggingStrategy($logger)) ->retry(maxAttempts: 3) ->build();
Exceptions
| Exception | Thrown when |
|---|---|
RetryExhaustedException |
All retry attempts failed. getPrevious() returns the last cause. |
CircuitOpenException |
A call is made while the circuit is Open. |
TimeoutExceededException |
Execution exceeded the configured duration. |
RateLimitExceededException |
The rate limit for the window has been reached. |
BulkheadFullException |
The maximum number of concurrent calls is already reached. |
TokenBucketExhaustedException |
The token bucket has no tokens available. |
FallbackNotAvailableException |
Both the primary call and the fallback handler failed. |
HedgeExhaustedException |
All hedge attempts failed. getExceptions() returns every cause. |
PHPStan integration
Aegis ships with a PHPStan rule that catches misuse at analysis time.
Rule: retryOn must contain Throwable subclasses
// PHPStan error: "stdClass" does not implement Throwable new RetryOptions(retryOn: [\stdClass::class]);
Install the extension via phpstan/extension-installer (automatic) or add manually:
# phpstan.neon includes: - vendor/kissmint3395/aegis/phpstan/extension.neon
Development
composer install # Tests ./vendor/bin/phpunit # Static analysis ./vendor/bin/phpstan analyse
Roadmap
- Rate Limiter (Fixed Window, Sliding Window, Token Bucket)
- Bulkhead (concurrency limiting)
- Fallback strategy
- Adaptive Circuit Breaker (sliding-window failure rate)
- Cache strategy
- Hedge strategy (experimental)
- PHPStan 2.x upgrade
License
MIT
日本語ドキュメント
PHP 8.2+ 向けのレジリエンスライブラリです。リトライ・サーキットブレーカー・タイムアウト・レートリミッター・バルクヘッド・フォールバック・キャッシュ・ヘッジを単一のコンポーザブルなパイプラインとして組み合わせられます。
Resilience4j(Java)や.NET Polly に相当するものが PHP エコシステムに存在しなかったため作成しました。
$pipeline = ResiliencePipeline::builder() ->circuitBreaker('payment-api', failureThreshold: 5) ->retry(maxAttempts: 3, backoff: ExponentialBackoff::withJitter(Duration::milliseconds(100))) ->timeout(Duration::seconds(5)) ->fallback(fn(\Throwable $e) => CachedResponse::last()) ->build(); $result = $pipeline->execute(fn() => $httpClient->post('/charge', $payload));
なぜ Aegis?
PHP には個別のレジリエンスライブラリが存在しますが、それらをパイプラインとして合成できるものはありませんでした。
| ライブラリ | リトライ | CB | タイムアウト | レートリミッター | バルクヘッド | フォールバック | キャッシュ | 合成可能 | PHP 8.2+ |
|---|---|---|---|---|---|---|---|---|---|
ackintosh/ganesha |
✗ | ✅ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✅ |
yohang/finite |
✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✅ |
cline/retry |
✅ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✅ |
| Aegis | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
要件
- PHP 8.2+
psr/event-dispatcher^1.0psr/simple-cache^3.0 (任意。サーキットブレーカー・レートリミッター・バルクヘッドの状態を永続化する場合)
インストール
composer require kissmint3395/aegis
使い方
リトライ
失敗した処理をバックオフ戦略付きで再試行します。
use Aegis\ResiliencePipeline; use Aegis\Backoff\ExponentialBackoff; use Aegis\Duration; $pipeline = ResiliencePipeline::builder() ->retry( maxAttempts: 3, backoff: ExponentialBackoff::withJitter(Duration::milliseconds(100)), retryOn: [\RuntimeException::class], ) ->build(); $result = $pipeline->execute(fn() => $api->fetch());
バックオフ戦略一覧:
use Aegis\Backoff\FixedBackoff; use Aegis\Backoff\ExponentialBackoff; // 固定遅延 new FixedBackoff(Duration::milliseconds(200)) // 指数バックオフ: 100ms → 200ms → 400ms → ... ExponentialBackoff::create(Duration::milliseconds(100)) // ジッター付き指数バックオフ: 各ステップの 50〜100% をランダムで選択 ExponentialBackoff::withJitter(Duration::milliseconds(100), maxDelay: Duration::seconds(5))
条件付きリトライ:
->retry( retryIf: fn(\Throwable $e) => $e->getCode() >= 500, )
サーキットブレーカー
サービスが不健全なときに呼び出しをブロックし、カスケード障害を防ぎます。
use Aegis\ResiliencePipeline; use Aegis\Duration; $pipeline = ResiliencePipeline::builder() ->circuitBreaker( name: 'inventory-api', failureThreshold: 5, successThreshold: 2, resetAfter: Duration::seconds(30), ) ->build();
状態遷移:
Closed ──(5回失敗)──► Open ──(30秒経過)──► HalfOpen
▲ │
└──────────(2回成功)────────────────────────────┘
(1回失敗)──► Open
リクエスト間で状態を永続化(Redis・APCu など):
use Aegis\Strategy\CircuitBreaker\Storage\Psr16Storage; $pipeline = ResiliencePipeline::builder() ->circuitBreaker('payment-api', storage: new Psr16Storage($redisCache)) ->build();
アダプティブ サーキットブレーカー
直近 N 件の**失敗率(%)**に基づいてサーキットを開く、スライディングウィンドウ型のサーキットブレーカーです。
$pipeline = ResiliencePipeline::builder() ->adaptiveCircuitBreaker( name: 'payment-api', windowSize: 20, // 直近 20 件を評価 failureRateThreshold: 50.0, // 50% 以上失敗でオープン minimumCalls: 5, // 5 件以上記録されるまでは評価しない successThreshold: 2, resetAfter: Duration::seconds(30), ) ->build();
タイムアウト
処理の最大実行時間を設定します。
->timeout(Duration::seconds(5))
注意:
pcntl拡張が利用可能な Unix 環境ではSIGALRMによるプリエンプティブな割り込みを行います。Windows やpcntlが使えない環境では、実行後に経過時間をチェックする方式にフォールバックします。
レートリミッター(固定ウィンドウ)
固定ウィンドウ内の呼び出し回数を制限します。
$pipeline = ResiliencePipeline::builder() ->rateLimit( name: 'payment-api', limit: 100, window: Duration::seconds(60), ) ->build();
レートリミッター(スライディングウィンドウ)
固定ウィンドウの「境界バースト問題」を解消します。個々のリクエストタイムスタンプを記録して正確にカウントします。
$pipeline = ResiliencePipeline::builder() ->slidingRateLimit( name: 'payment-api', limit: 100, window: Duration::seconds(60), ) ->build();
レートリミッター(トークンバケット)
最大 capacity バーストを許容しつつ、refillRate / refillPeriod の平均レートを連続補充で維持します。
$pipeline = ResiliencePipeline::builder() ->tokenBucketRateLimit( name: 'payment-api', capacity: 20, refillRate: 10, refillPeriod: Duration::seconds(1), ) ->build();
バルクヘッド
同時実行数を制限してリソース枯渇を防ぎます。
$pipeline = ResiliencePipeline::builder() ->bulkhead( name: 'database', maxConcurrent: 10, ) ->build();
フォールバック
全戦略が失敗したとき、代替値を返すか代替処理を実行します。
$pipeline = ResiliencePipeline::builder() ->circuitBreaker('payment-api') ->retry(maxAttempts: 3) ->fallback(fn(\Throwable $e) => ['status' => 'unavailable']) ->build();
フォールバックハンドラー自体が例外をスローした場合は FallbackNotAvailableException が発生します。
キャッシュ
成功したレスポンスをキャッシュし、次回以降は呼び出しをスキップします。staleOnFailure: true を指定すると、ダウンストリームが失敗した際に期限切れのキャッシュを返します。
use Aegis\Strategy\Cache\Storage\CacheInMemoryStorage; $pipeline = ResiliencePipeline::builder() ->cache( name: 'product-catalog', ttl: Duration::minutes(5), staleOnFailure: true, storage: new CacheInMemoryStorage(), ) ->build();
ヘッジ(実験的)
一定時間内に応答がない場合、追加のリクエストを並行して投げ、最初に成功したものを採用します(テールレイテンシー対策)。
$pipeline = ResiliencePipeline::builder() ->hedge( delay: Duration::milliseconds(200), maxHedges: 1, ) ->build();
注意: 真のレイテンシーベースのヘッジには、
Fiber::suspend()を使用した協調型非同期(ReactPHP / Swoole など)が必要です。同期的な callable の場合、ヘッジは各失敗後に即座に発火します。
戦略の合成
戦略は追加した順に外側から適用されます(最初に追加 = 最も外側)。 推奨順序は Bulkhead → Rate Limiter → Timeout → Circuit Breaker → Retry → Fallback です。
$pipeline = ResiliencePipeline::builder() ->bulkhead('svc', maxConcurrent: 10) ->rateLimit('svc', limit: 100) ->timeout(Duration::seconds(10)) ->circuitBreaker('svc', failureThreshold: 5) ->retry(maxAttempts: 3) ->fallback(fn() => $defaultValue) ->build();
PSR-14 イベント
PSR-14 のイベントディスパッチャーを接続して、パイプライン内部の動作を観測できます。
$pipeline = ResiliencePipeline::builder() ->withEventDispatcher($dispatcher) ->circuitBreaker('api') ->retry(maxAttempts: 3) ->build();
| イベント | 発火タイミング |
|---|---|
RetryAttempted |
リトライ待機前 |
CircuitOpened |
Closed → Open に遷移したとき |
CircuitClosed |
HalfOpen → Closed に遷移したとき |
CircuitHalfOpened |
Open → HalfOpen に遷移したとき |
RateLimitExceeded |
レート制限に達して呼び出しを拒否したとき |
BulkheadRejected |
バルクヘッドが満杯で呼び出しを拒否したとき |
TokenBucketExhausted |
トークンバケットが空で呼び出しを拒否したとき |
カスタム戦略
StrategyInterface を実装して独自の戦略を追加できます。
use Aegis\Contract\StrategyInterface; final class LoggingStrategy implements StrategyInterface { public function __construct(private readonly LoggerInterface $logger) {} public function execute(callable $next): mixed { $start = microtime(true); try { $result = $next(); $this->logger->info('成功', ['ms' => (int)((microtime(true) - $start) * 1000)]); return $result; } catch (\Throwable $e) { $this->logger->error($e->getMessage()); throw $e; } } } $pipeline = ResiliencePipeline::builder() ->addStrategy(new LoggingStrategy($logger)) ->retry(maxAttempts: 3) ->build();
例外一覧
| 例外 | 発生タイミング |
|---|---|
RetryExhaustedException |
全リトライが失敗。getPrevious() で最後の原因を取得可能 |
CircuitOpenException |
サーキットがオープン状態のときに呼び出しを行った |
TimeoutExceededException |
設定した時間内に処理が完了しなかった |
RateLimitExceededException |
ウィンドウ内のレート制限に達した |
BulkheadFullException |
最大同時実行数に達している |
TokenBucketExhaustedException |
トークンバケットにトークンがない |
FallbackNotAvailableException |
本処理とフォールバックの両方が失敗した |
HedgeExhaustedException |
全ヘッジ試行が失敗。getExceptions() で全原因を取得可能 |
PHPStan 連携
Aegis には静的解析時に誤用を検出する PHPStan ルールが同梱されています。
// PHPStan エラー: "stdClass" は Throwable を実装していない new RetryOptions(retryOn: [\stdClass::class]);
phpstan/extension-installer 経由で自動的に有効化されます。手動で追加する場合:
# phpstan.neon includes: - vendor/kissmint3395/aegis/phpstan/extension.neon
開発
composer install # テスト実行 ./vendor/bin/phpunit # 静的解析 ./vendor/bin/phpstan analyse
ロードマップ
- レートリミッター(固定ウィンドウ・スライディングウィンドウ・トークンバケット)
- バルクヘッド(同時実行数制限)
- フォールバック戦略
- アダプティブ サーキットブレーカー(失敗率ベース)
- キャッシュ戦略
- ヘッジ戦略(実験的)
- PHPStan 2.x 対応