diff --git a/README.md b/README.md index 5f8c2a5..9308cf7 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,18 @@ composer require stfndamjanovic/php-circuit-breaker ```php use Stfn\CircuitBreaker\CircuitBreakerFactory; +use Stfn\CircuitBreaker\Exceptions\CircuitHalfOpenFailException; $result = CircuitBreakerFactory::make() ->for('test-service') + ->failWhen(function ($result) { + if ($result->status > 400) { + throw new Exception(); + } + }) + ->skipFailure(function (Exception $exception) { + return $exception instanceof CircuitHalfOpenFailException; + }) ->withOptions([ 'recovery_time' => 30, 'failure_threshold' => 5 diff --git a/src/CircuitBreaker.php b/src/CircuitBreaker.php index 007244a..17dc9d2 100755 --- a/src/CircuitBreaker.php +++ b/src/CircuitBreaker.php @@ -6,6 +6,7 @@ use Stfn\CircuitBreaker\StateHandlers\ClosedStateHandler; use Stfn\CircuitBreaker\StateHandlers\HalfOpenStateHandler; use Stfn\CircuitBreaker\StateHandlers\OpenStateHandler; +use Stfn\CircuitBreaker\StateHandlers\StateHandler; use Stfn\CircuitBreaker\Storage\CircuitBreakerStorage; use Stfn\CircuitBreaker\Storage\InMemoryStorage; @@ -21,6 +22,21 @@ class CircuitBreaker */ public CircuitBreakerStorage $storage; + /** + * @var CircuitBreakerListener[] + */ + public array $listeners = []; + + /** + * @var \Closure|null + */ + public \Closure|null $failWhenCallback = null; + + /** + * @var \Closure|null + */ + public \Closure|null $skipFailureCallback = null; + /** * @param Config|null $config * @param CircuitBreakerStorage|null $storage @@ -39,6 +55,7 @@ public function __construct(Config $config = null, CircuitBreakerStorage $storag */ public function call(\Closure $action, ...$args) { + /** @var StateHandler $stateHandler */ $stateHandler = $this->makeStateHandler(); return $stateHandler->call($action, $args); @@ -88,4 +105,13 @@ public function isOpen() { return $this->storage->getState() !== CircuitState::Closed; } + + /** + * @param CircuitBreakerListener $listener + * @return void + */ + public function addListener(CircuitBreakerListener $listener) + { + $this->listeners[] = $listener; + } } diff --git a/src/CircuitBreakerFactory.php b/src/CircuitBreakerFactory.php index f174f65..1cf524a 100644 --- a/src/CircuitBreakerFactory.php +++ b/src/CircuitBreakerFactory.php @@ -6,7 +6,7 @@ class CircuitBreakerFactory { - protected CircuitBreaker $circuitBreaker; + public CircuitBreaker $circuitBreaker; public static function make() { @@ -23,13 +23,36 @@ public function for(string $service) return $this; } - public function withOptions(array $options) + public function withOptions(array $options): self { $this->circuitBreaker->config = Config::make($options); return $this; } + public function withListeners(array $listeners): self + { + foreach ($listeners as $listener) { + $this->circuitBreaker->addListener($listener); + } + + return $this; + } + + public function skipFailure(\Closure $closure) + { + $this->circuitBreaker->skipFailureCallback = $closure; + + return $this; + } + + public function failWhen(\Closure $closure) + { + $this->circuitBreaker->failWhenCallback = $closure; + + return $this; + } + public function storage(CircuitBreakerStorage $storage) { $this->circuitBreaker->storage = $storage; diff --git a/src/CircuitBreakerListener.php b/src/CircuitBreakerListener.php new file mode 100644 index 0000000..cc678ee --- /dev/null +++ b/src/CircuitBreakerListener.php @@ -0,0 +1,16 @@ +handleSucess(); + $this->handleSucess($result); } catch (\Exception $exception) { $this->handleFailure($exception); } @@ -53,21 +53,42 @@ public function beforeCall(\Closure $action, ...$args) /** * @param \Exception $exception - * @return void + * @return mixed + * @throws \Exception */ public function handleFailure(\Exception $exception) { - //@ToDO Add listeners here + if (is_callable($this->breaker->skipFailureCallback)) { + $shouldSkip = call_user_func($this->breaker->skipFailureCallback, $exception); + + if ($shouldSkip) { + return; + } + } + + foreach ($this->breaker->listeners as $listener) { + $listener->onFail($exception); + } + $this->onFailure($exception); + + throw $exception; } /** * @return void */ - public function handleSucess() + public function handleSucess($result) { - // @ToDo Add listeners here + if (is_callable($this->breaker->failWhenCallback)) { + call_user_func($this->breaker->failWhenCallback, $result); + } + $this->onSucess(); + + foreach ($this->breaker->listeners as $listener) { + $listener->onSuccess($result); + } } /** diff --git a/src/Storage/RedisStorage.php b/src/Storage/RedisStorage.php index ab395b0..5b2ad86 100644 --- a/src/Storage/RedisStorage.php +++ b/src/Storage/RedisStorage.php @@ -16,22 +16,16 @@ class RedisStorage extends CircuitBreakerStorage */ protected \Redis $redis; - /** - * @var - */ - protected string $namespace; - /** * @param string $service * @param \Redis $redis * @throws \RedisException */ - public function __construct(string $namespace, string $service, \Redis $redis) + public function __construct(\Redis $redis, string $service) { parent::__construct($service); $this->redis = $redis; - $this->namespace = $namespace; $this->initState(); } @@ -109,7 +103,7 @@ public function openedAt(): int */ protected function getNamespace(string $key): string { - $tags = [self::BASE_NAMESPACE, $this->namespace, $this->service, $key]; + $tags = [self::BASE_NAMESPACE, $this->service, $key]; return join(":", $tags); } diff --git a/tests/CircuitBreakerTest.php b/tests/CircuitBreakerTest.php index 76cc0cc..7b4225c 100644 --- a/tests/CircuitBreakerTest.php +++ b/tests/CircuitBreakerTest.php @@ -4,11 +4,12 @@ use PHPUnit\Framework\TestCase; use Stfn\CircuitBreaker\CircuitBreaker; +use Stfn\CircuitBreaker\CircuitBreakerFactory; +use Stfn\CircuitBreaker\CircuitBreakerListener; use Stfn\CircuitBreaker\CircuitState; use Stfn\CircuitBreaker\Config; use Stfn\CircuitBreaker\Exceptions\CircuitHalfOpenFailException; use Stfn\CircuitBreaker\Exceptions\CircuitOpenException; -use Stfn\CircuitBreaker\Storage\RedisStorage; class CircuitBreakerTest extends TestCase { @@ -75,7 +76,11 @@ public function test_if_it_will_record_every_failure() $tries = 3; foreach (range(1, $tries) as $i) { - $breaker->call($fail); + try { + $breaker->call($fail); + } catch (\Exception) { + + } } $this->assertEquals($tries, $breaker->storage->getFailuresCount()); @@ -162,6 +167,82 @@ public function test_if_it_will_transit_back_to_open_state_after_first_fail() $this->assertTrue($breaker->isOpen()); } + public function test_if_listener_is_called() + { + $object = new class extends CircuitBreakerListener { + public int $successCount = 0; + public int $failCount = 0; + + public function onSuccess($result): void + { + $this->successCount++; + } + + public function onFail($exception): void + { + $this->failCount++; + } + }; + + $factory = CircuitBreakerFactory::make() + ->withOptions(['failure_threshold' => 10]) + ->withListeners([$object]); + + $success = function () { + return true; + }; + + $fail = function () { + throw new \Exception(); + }; + + $factory->call($success); + $factory->call($success); + + try { + $factory->call($fail); + } catch (\Exception) { + + } + + $this->assertEquals(2, $object->successCount); + $this->assertEquals(1, $object->failCount); + } + + public function test_if_it_can_skip_some_exception() + { + $testException = new class extends \Exception {}; + + $factory = CircuitBreakerFactory::make() + ->skipFailure(function (\Exception $exception) use ($testException) { + return $exception instanceof $testException; + }); + + $factory->call(function () use ($testException) { + throw $testException; + }); + + $this->assertEquals(0, $factory->circuitBreaker->storage->getFailuresCount()); + } + + public function test_if_it_can_fail_even_without_exception() + { + $factory = CircuitBreakerFactory::make() + ->failWhen(function ($result) { + if ($result instanceof \stdClass) { + throw new \Exception(); + } + }); + + try { + $factory->call(fn() => new \stdClass()); + } catch (\Exception) { + + } + + $this->assertEquals(1, $factory->circuitBreaker->storage->getFailuresCount()); + } + // public function test_if_redis_work() // { // $redis = new \Redis();