diff --git a/src/Contracts/CircuitBreaker.php b/src/Contracts/CircuitBreaker.php index 2ded73e..30d1be1 100644 --- a/src/Contracts/CircuitBreaker.php +++ b/src/Contracts/CircuitBreaker.php @@ -39,4 +39,28 @@ public function isHalfOpened(): bool; * @return bool checks if the circuit breaker is closed */ public function isClosed(): bool; + + /** + * @return bool checks if the circuit breaker is isolated + */ + public function isIsolated(): bool; + + /** + * Manually open (and hold open) the Circuit Breaker + * This can be used for example to take it offline for maintenance. + * + * @param string $service the service to call + * + * @return self + */ + public function isolate(string $service): self; + + /** + * Reset the breaker to closed state to start accepting actions again. + * + * @param string $service the service to call + * + * @return self + */ + public function reset(string $service): self; } diff --git a/src/MainCircuitBreaker.php b/src/MainCircuitBreaker.php index ec4a936..63c7598 100644 --- a/src/MainCircuitBreaker.php +++ b/src/MainCircuitBreaker.php @@ -63,6 +63,10 @@ public function call(string $service, callable $fallback, array $serviceParamete { $transaction = $this->initTransaction($service, $serviceParameters); + if ($this->isIsolated()) { + return (string) $fallback(); + } + if ($this->isOpened()) { if (!$this->canAccessService($transaction)) { return (string) $fallback(); @@ -140,6 +144,38 @@ public function isClosed(): bool return States::CLOSED_STATE === $this->currentPlace->getState(); } + /** + * {@inheritdoc} + */ + public function isIsolated(): bool + { + return States::ISOLATED_STATE === $this->currentPlace->getState(); + } + + /** + * {@inheritdoc} + */ + public function isolate(string $service): CircuitBreaker + { + $this->currentPlace = $this->places[States::ISOLATED_STATE]; + + $this->dispatch(Transitions::ISOLATING_TRANSITION, $service, []); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function reset(string $service): CircuitBreaker + { + $this->currentPlace = $this->places[States::CLOSED_STATE]; + + $this->dispatch(Transitions::RESETTING_TRANSITION, $service, []); + + return $this; + } + /** * @param string $state the Place state * @param string $service the service URI diff --git a/src/Places/ClosedPlace.php b/src/Places/ClosedPlace.php index eb001f3..cac4cd0 100644 --- a/src/Places/ClosedPlace.php +++ b/src/Places/ClosedPlace.php @@ -4,6 +4,12 @@ use Resiliency\States; +/** + * The circuit initially starts closed. When the circuit is closed: + * + * The circuit-breaker executes actions placed through it, measuring the failures and successes of those actions. + * If the failures exceed a certain threshold, the circuit will break (open). + */ final class ClosedPlace extends AbstractPlace { /** diff --git a/src/Places/HalfOpenPlace.php b/src/Places/HalfOpenPlace.php index e66c7c5..3edbb3c 100644 --- a/src/Places/HalfOpenPlace.php +++ b/src/Places/HalfOpenPlace.php @@ -4,6 +4,16 @@ use Resiliency\States; +/** + * When the circuit is half-open: + * + * the next action will be treated as a trial, to determine the circuit's health. + * + * If this call throws a handled exception, that exception is rethrown, + * and the circuit transitions immediately back to open, and remains open again for the configured timespan. + * + * If the call throws no exception, the circuit transitions back to closed. + */ final class HalfOpenPlace extends AbstractPlace { /** diff --git a/src/Places/IsolatedPlace.php b/src/Places/IsolatedPlace.php new file mode 100644 index 0000000..b167a05 --- /dev/null +++ b/src/Places/IsolatedPlace.php @@ -0,0 +1,25 @@ +places = [ $closedPlace->getState() => $closedPlace, $halfOpenPlace->getState() => $halfOpenPlace, $openPlace->getState() => $openPlace, + $isolatedPlace->getState() => $isolatedPlace, ]; } diff --git a/src/Transitions.php b/src/Transitions.php index ab78c0c..dbda93e 100644 --- a/src/Transitions.php +++ b/src/Transitions.php @@ -40,4 +40,14 @@ final class Transitions * Happened on each try to call the service. */ const TRIAL_TRANSITION = 'TRIAL'; + + /** + * Happened when the Circuit Breaker is isolated. + */ + const ISOLATING_TRANSITION = 'ISOLATING'; + + /** + * Happened when the Circuit Breaker is reset. + */ + const RESETTING_TRANSITION = 'RESETTING'; } diff --git a/tests/CircuitBreakerWorkflowTest.php b/tests/CircuitBreakerWorkflowTest.php index 6d0fc12..7952402 100644 --- a/tests/CircuitBreakerWorkflowTest.php +++ b/tests/CircuitBreakerWorkflowTest.php @@ -100,6 +100,33 @@ public function testOnceInHalfOpenModeServiceIsFinallyReachable(CircuitBreaker $ $this->assertTrue($circuitBreaker->isClosed()); } + /** + * The Circuit Breaker can be isolated, once its done it remains + * Open and so on only fallback responses will be sent. + * + * @dataProvider getCircuitBreakers + */ + public function testOnceCircuitBreakerIsIsolatedNoTrialsAreDone(CircuitBreaker $circuitBreaker): void + { + $circuitBreaker->isolate('https://httpbin.org/get/foo'); + + $response = $circuitBreaker->call('https://httpbin.org/get/foo', $this->createFallbackResponse()); + $this->assertSame('{}', $response); + $this->assertTrue($circuitBreaker->isIsolated()); + + // Let's do 10 calls! + + for ($i = 0; $i < 10; ++$i) { + $circuitBreaker->call('https://httpbin.org/get/foo', $this->createFallbackResponse()); + $this->assertSame('{}', $response); + $this->assertTrue($circuitBreaker->isIsolated()); + } + + $circuitBreaker->reset('https://httpbin.org/get/foo'); + + $this->assertTrue($circuitBreaker->isClosed()); + } + /** * Return the list of supported circuit breakers * diff --git a/tests/SymfonyCircuitBreakerEventsTest.php b/tests/SymfonyCircuitBreakerEventsTest.php index bbab39f..07c2b7f 100644 --- a/tests/SymfonyCircuitBreakerEventsTest.php +++ b/tests/SymfonyCircuitBreakerEventsTest.php @@ -52,6 +52,38 @@ function () { $this->assertSame('resiliency.opening', $invocations[3]->getParameters()[0]); } + public function testCircuitBreakerEventsOnIsolationAndResetActions(): void + { + $service = 'https://httpbin.org/get/foobaz'; + $circuitBreaker = $this->createCircuitBreaker(); + + $circuitBreaker->call( + $service, + function () { + return '{}'; + } + ); + + $circuitBreaker->isolate($service); + + /** + * The circuit breaker is now isolated and + * the related event has been dispatched + */ + $invocations = $this->spy->getInvocations(); + $this->assertCount(5, $invocations); + $this->assertSame('resiliency.isolating', $invocations[4]->getParameters()[0]); + + /* + * And now we reset the circuit breaker! + * The related event must be dispatched + */ + $circuitBreaker->reset($service); + $invocations = $this->spy->getInvocations(); + $this->assertCount(6, $invocations); + $this->assertSame('resiliency.resetting', $invocations[5]->getParameters()[0]); + } + private function createCircuitBreaker(): CircuitBreaker { $system = $this->getSystem(); diff --git a/tests/System/MainSystemTest.php b/tests/System/MainSystemTest.php index cfb1e0c..53315da 100644 --- a/tests/System/MainSystemTest.php +++ b/tests/System/MainSystemTest.php @@ -36,7 +36,7 @@ public function testGetPlaces(): void $places = $mainSystem->getPlaces(); $this->assertIsArray($places); - $this->assertCount(3, $places); + $this->assertCount(4, $places); foreach ($places as $place) { $this->assertInstanceOf(Place::class, $place);