├── .php-cs-fixer.dist.php ├── README.md ├── composer.json └── src ├── Clients ├── ClientHelper.php ├── GuzzleClient.php └── SymfonyClient.php ├── Contracts ├── CircuitBreaker.php ├── Client.php ├── Event.php ├── Exception.php ├── Monitoring │ ├── Monitor.php │ ├── Report.php │ └── ReportEntry.php ├── Place.php ├── Service.php ├── Storage.php ├── System.php ├── ThrowableEvent.php └── Transaction.php ├── Events ├── AvailabilityChecked.php ├── Closed.php ├── Failed.php ├── Initiated.php ├── Isolated.php ├── Opened.php ├── ReOpened.php ├── Reseted.php ├── TransitionEvent.php └── Tried.php ├── Exceptions ├── InvalidPlace.php ├── InvalidSystem.php ├── InvalidTransaction.php ├── TransactionNotFound.php └── UnavailableService.php ├── MainCircuitBreaker.php ├── MainService.php ├── Monitors ├── SimpleMonitor.php ├── SimpleReport.php └── SimpleReportEntry.php ├── Places ├── Closed.php ├── HalfOpened.php ├── Isolated.php ├── Opened.php └── PlaceHelper.php ├── States.php ├── Storages ├── SimpleArray.php └── SimpleCache.php ├── Systems └── MainSystem.php ├── Transactions └── SimpleTransaction.php ├── Transitions.php └── Utils ├── Assert.php └── ErrorFormatter.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 4 | __DIR__.'/src', 5 | __DIR__.'/tests', 6 | ]); 7 | 8 | $config = new PhpCsFixer\Config(); 9 | return $config 10 | ->setRiskyAllowed(true) 11 | ->setRules([ 12 | '@Symfony' => true, 13 | 'concat_space' => [ 14 | 'spacing' => 'one', 15 | ], 16 | 'cast_spaces' => [ 17 | 'space' => 'single', 18 | ], 19 | 'error_suppression' => [ 20 | 'mute_deprecation_error' => false, 21 | 'noise_remaining_usages' => false, 22 | 'noise_remaining_usages_exclude' => [], 23 | ], 24 | 'function_to_constant' => false, 25 | 'no_alias_functions' => false, 26 | 'non_printable_character' => false, 27 | 'phpdoc_summary' => false, 28 | 'phpdoc_align' => [ 29 | 'align' => 'left', 30 | ], 31 | 'protected_to_private' => false, 32 | 'self_accessor' => false, 33 | 'yoda_style' => false, 34 | 'non_printable_character' => true, 35 | 'phpdoc_no_empty_return' => false, 36 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], 37 | ]) 38 | ->setFinder($finder) 39 | ->setCacheFile(__DIR__.'/.php_cs.cache') 40 | ; 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Resiliency, an implementation for resilient and modern PHP applications 2 | 3 | [![codecov](https://codecov.io/gh/loveOSS/resiliency/branch/master/graph/badge.svg)](https://codecov.io/gh/loveOSS/resiliency) [![PHPStan](https://img.shields.io/badge/PHPStan-Level%20max-brightgreen.svg?style=flat&logo=php)](https://shields.io/#/) [![Psalm](https://img.shields.io/badge/Psalm-Level%20Max-brightgreen.svg?style=flat&logo=php)](https://shields.io/#/) [![Build Status](https://travis-ci.com/loveOSS/resiliency.svg?branch=master)](https://travis-ci.com/loveOSS/resiliency) 4 | 5 | ## Main principles 6 | 7 | ![circuit breaker](https://user-images.githubusercontent.com/1247388/49721725-438bd700-fc63-11e8-8498-82ca681b15fb.png) 8 | 9 | This library is compatible with PHP 7.4+. 10 | 11 | ## Installation 12 | 13 | ``` 14 | composer require love-oss/resiliency 15 | ``` 16 | 17 | ## Use 18 | 19 | You need to configure a system for the Circuit Breaker: 20 | 21 | * the **failures**: define how many times we try to access the service; 22 | * the **timeout**: define how long we wait (in ms) before consider the service unreachable; 23 | * the **stripped timeout**: define how long we wait (in ms) before consider the service unreachable, once we're in half open state; 24 | * the **threshold**: define how long we wait (in ms) before trying to access again the service; 25 | * the (HTTP|HTTPS) **client** that will be used to reach the services; 26 | * the **fallback** callback will be used if the distant service is unreachable when the Circuit Breaker is Open (means "is used"). 27 | 28 | > You'd better return the same type of response expected from your distant call. 29 | 30 | ```php 31 | use Resiliency\MainCircuitBreaker; 32 | use Resiliency\Systems\MainSystem; 33 | use Resiliency\Storages\SimpleArray; 34 | use Resiliency\Clients\SymfonyClient; 35 | use Symfony\Component\HttpClient\HttpClient; 36 | 37 | $client = new SymfonyClient(HttpClient::create()); 38 | 39 | $mainSystem = MainSystem::createFromArray([ 40 | 'failures' => 2, 41 | 'timeout' => 100, 42 | 'stripped_timeout' => 200, 43 | 'threshold' => 10000, 44 | ], $client); 45 | 46 | $storage = new SimpleArray(); 47 | 48 | // Any PSR-14 Event Dispatcher implementation. 49 | $dispatcher = new Symfony\Component\EventDispatcher\EventDispatcher; 50 | 51 | $circuitBreaker = new MainCircuitBreaker( 52 | $mainSystem, 53 | $storage, 54 | $dispatcher 55 | ); 56 | 57 | /** 58 | * @var Service $service 59 | */ 60 | $fallbackResponse = function ($service) { 61 | return '{}'; 62 | }; 63 | 64 | $circuitBreaker->call( 65 | 'https://api.domain.com', 66 | $fallbackResponse, 67 | [ 68 | 'query' => [ 69 | '_token' => '123456789', 70 | ] 71 | ] 72 | ); 73 | ``` 74 | 75 | ### Clients 76 | 77 | Resiliency library supports both [Guzzle (v6 & v7)](http://docs.guzzlephp.org/en/stable/index.html) and HttpClient Component from [Symfony (v4 & v5)](https://symfony.com/doc/current/components/http_client.html). 78 | 79 | ### Monitoring 80 | 81 | This library provides a minimalist system to help you monitor your circuits. 82 | 83 | ```php 84 | $monitor = new SimpleMonitor(); 85 | 86 | // Collect information while listening 87 | // to some circuit breaker events... 88 | function listener(Event $event) { 89 | $monitor->add($event); 90 | }; 91 | 92 | // Retrieve a complete report for analysis or storage 93 | $report = $monitor->getReport(); 94 | ``` 95 | 96 | ## Tests 97 | 98 | ``` 99 | composer test 100 | ``` 101 | 102 | ## Code quality 103 | 104 | This library has high quality standards: 105 | 106 | ``` 107 | composer cs-fix && composer phpstan && composer psalm && composer phpqa 108 | ``` 109 | 110 | We also use [PHPQA](https://github.com/EdgedesignCZ/phpqa#phpqa) to check the Code quality 111 | during the CI management of the contributions: 112 | 113 | ``` 114 | composer phpqa 115 | ``` 116 | 117 | ## I've heard of the PrestaShop Circuit Breaker: what library should I use ? 118 | 119 | Welcome, that's an interesting question ! 120 | 121 | Above all, I must say that I'm the former author of the PrestaShop [Circuit Breaker](https://github.com/PrestaShop/circuit-breaker) library 122 | and I have decided to fork my own library to be able to improve it without the constraints of the PrestaShop CMS main project. 123 | 124 | As of now (June, 2021), these libraries have a lot in common ! 125 | 126 | They share almost the same API, and the PrestaShop Core Team have created multiple implementations of [Circuit Breaker interface](https://github.com/PrestaShop/circuit-breaker/blob/develop/src/AdvancedCircuitBreaker.php) and [Factory](https://github.com/PrestaShop/circuit-breaker/blob/develop/src/AdvancedCircuitBreakerFactory.php) : 127 | 128 | * SimpleCircuitBreaker 129 | * AdvancedCircuitBreaker 130 | * PartialCircuitBreaker 131 | * SymfonyCircuitBreaker 132 | 133 | 134 | 1. They maintain a version compatible with PHP 7.2+ and Symfony 4 but not (yet ?) with PHP 8 and Symfony 5 ; 135 | 2. They have a dependency on their own package named [php-dev-tools](https://github.com/PrestaShop/php-dev-tools) ; 136 | 3. They maintain an implementation of [Storage](https://github.com/PrestaShop/circuit-breaker/blob/v4.0.0/src/Storage/DoctrineCache.php) using Doctrine Cache library ; 137 | 4. They don't have a Symfony HttpClient implementation ; 138 | 5. For the events, I'm not sure as their implementation make the list difficult to establish ; 139 | 6. They don't provide a mecanism to reset and restore a Circuit Breaker ; 140 | 7. They don't provide a mecanism to monitor the activity of a Circuit Breaker ; 141 | 8. They have removed [Psalm](https://psalm.dev/) from their CI and they don't use [PHPQA](https://github.com/EdgedesignCZ/phpqa) ; 142 | 9. They have added `declare(strict_types=1);` on all the files ; 143 | 10. They don't declare a `.gitattributes` file, this means that all tests are downloaded when [we require](https://madewithlove.com/blog/software-engineering/gitattributes/) their library ; 144 | 145 | > All right ... but this don't tell me what library should I use in my project ! 146 | 147 | * If you need PHP 5.6, use Circuit Breaker v3 148 | * If you need PHP 7.2, use Circuit Breaker v4 149 | * If you need PHP 7.4+, use Resiliency 150 | * If you need a library maintained by a team of developers, use PrestaShop 151 | * If you trust [me](https://github.com/mickaelandrieu) to maintain this package _almost_ all alone, use Resiliency ! 152 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "love-oss/resiliency", 3 | "description": "A circuit breaker implementation for PHP 7.4+", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Mickaël Andrieu", 9 | "email": "mickael.andrieu@solvolabs.com" 10 | }, 11 | { 12 | "name": "Resiliency Community", 13 | "homepage": "https://github.com/loveOSS/resiliency/graphs/contributors" 14 | } 15 | ], 16 | "require": { 17 | "php": "^7.4|^8.0", 18 | "ext-json": "*", 19 | "psr/event-dispatcher": "^1.0", 20 | "psr/simple-cache": "^1.0" 21 | }, 22 | "require-dev": { 23 | "edgedesign/phpqa": "^v1.24", 24 | "friendsofphp/php-cs-fixer": "^3.5", 25 | "guzzlehttp/guzzle": "^6.3|^7.0", 26 | "php-parallel-lint/php-parallel-lint": "^1.0", 27 | "phpstan/phpstan": "^1.2", 28 | "phpstan/phpstan-phpunit": "^1.0", 29 | "phpunit/phpunit": "^9.0", 30 | "symfony/cache": "~4.4|~5.4|~6.0", 31 | "symfony/http-client": "^4.4|~5.4|~6.0", 32 | "vimeo/psalm": "^4.3" 33 | }, 34 | "suggest": { 35 | "ext-apcu": "Allows use of APCu adapter (performant) to store transactions", 36 | "guzzlehttp/guzzle": "Allows use of Guzzle 6 HTTP Client", 37 | "symfony/cache": "Allows use of Symfony Cache adapters to store transactions", 38 | "symfony/http-client": "Allows use of any Symfony HTTP Clients" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Resiliency\\": "src/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Tests\\Resiliency\\": "tests/" 48 | } 49 | }, 50 | "scripts": { 51 | "cs-fix": "@php ./vendor/bin/php-cs-fixer fix", 52 | "phpqa": "@php ./vendor/bin/phpqa --report --tools phpcs:0,phpmetrics,phploc,pdepend,security-checker:0,parallel-lint:0 --ignoredDirs tests,vendor", 53 | "phpstan": "@php ./vendor/bin/phpstan analyse src --level max -c extension.neon", 54 | "psalm": "@php ./vendor/bin/psalm --threads=8 --diff", 55 | "test": "@php ./vendor/bin/phpunit" 56 | }, 57 | "scripts-descriptions": { 58 | "cs-fix": "Check and fix coding styles using PHP CS Fixer", 59 | "phpqa": "Execute PHQA toolsuite analysis", 60 | "phpstan": "Execute PHPStan analysis", 61 | "psalm": "Execute Psalm analysis", 62 | "test": "Launch PHPUnit test suite" 63 | }, 64 | "config": { 65 | "sort-packages": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Clients/ClientHelper.php: -------------------------------------------------------------------------------- 1 | mainOptions = $mainOptions; 16 | } 17 | 18 | /** 19 | * @param array $options the list of options 20 | * 21 | * @return string the method 22 | */ 23 | protected function defineMethod(array $options): string 24 | { 25 | if (isset($this->mainOptions['method'])) { 26 | return (string) $this->mainOptions['method']; 27 | } 28 | 29 | if (isset($options['method'])) { 30 | return (string) $options['method']; 31 | } 32 | 33 | return self::DEFAULT_METHOD; 34 | } 35 | 36 | protected function convertToSeconds(int $milliseconds): float 37 | { 38 | return $milliseconds / 1_000; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | abstract public function request(Service $service, Place $place): string; 45 | } 46 | -------------------------------------------------------------------------------- /src/Clients/GuzzleClient.php: -------------------------------------------------------------------------------- 1 | mainOptions); 25 | $method = $this->defineMethod($service->getParameters()); 26 | $options['http_errors'] = true; 27 | $options['connect_timeout'] = $this->convertToSeconds($place->getTimeout()); 28 | $options['timeout'] = $this->convertToSeconds($place->getTimeout()); 29 | 30 | $clientParameters = array_merge($service->getParameters(), $options); 31 | 32 | return (string) $client->request($method, $service->getURI(), $clientParameters)->getBody(); 33 | } catch (Exception $exception) { 34 | throw new UnavailableService($exception->getMessage(), (int) $exception->getCode(), $exception); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Clients/SymfonyClient.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient; 24 | parent::__construct($mainOptions); 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function request(Service $service, Place $place): string 31 | { 32 | $options = []; 33 | try { 34 | $method = $this->defineMethod($service->getParameters()); 35 | $options['timeout'] = $this->convertToSeconds($place->getTimeout()); 36 | 37 | $clientParameters = array_merge($service->getParameters(), $options); 38 | unset($clientParameters['method']); 39 | 40 | return $this->httpClient->request($method, $service->getURI(), $clientParameters)->getContent(); 41 | } catch (TransportExceptionInterface $exception) { 42 | throw new UnavailableService($exception->getMessage(), (int) $exception->getCode(), $exception); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Contracts/CircuitBreaker.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 26 | 27 | parent::__construct($circuitBreaker, $service); 28 | } 29 | 30 | public function getException(): Exception 31 | { 32 | return $this->exception; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Events/Initiated.php: -------------------------------------------------------------------------------- 1 | circuitBreaker = $circuitBreaker; 21 | $this->service = $service; 22 | } 23 | 24 | /** 25 | * @return CircuitBreaker the Circuit Breaker 26 | */ 27 | public function getCircuitBreaker(): CircuitBreaker 28 | { 29 | return $this->circuitBreaker; 30 | } 31 | 32 | /** 33 | * @return Service the Service 34 | */ 35 | public function getService(): Service 36 | { 37 | return $this->service; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Events/Tried.php: -------------------------------------------------------------------------------- 1 | currentPlace = $system->getInitialPlace(); 28 | $this->currentPlace->setCircuitBreaker($this); 29 | $this->places = $system->getPlaces(); 30 | $this->storage = $storage; 31 | $this->dispatcher = $dispatcher; 32 | } 33 | 34 | private Place $currentPlace; 35 | 36 | /** 37 | * @var Place[] the Circuit Breaker places 38 | */ 39 | private array $places; 40 | private Storage $storage; 41 | private EventDispatcherInterface $dispatcher; 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function call(string $uri, callable $fallback, array $uriParameters = []): string 47 | { 48 | $service = new MainService($uri, $uriParameters); 49 | $transaction = $this->initTransaction($service); 50 | 51 | return $this->currentPlace->call($transaction, $fallback); 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function getState(): Place 58 | { 59 | return $this->currentPlace; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function getStorage(): Storage 66 | { 67 | return $this->storage; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function getDispatcher(): EventDispatcherInterface 74 | { 75 | return $this->dispatcher; 76 | } 77 | 78 | /** 79 | * {@inheritdoc} 80 | */ 81 | public function isolate(string $uri): CircuitBreaker 82 | { 83 | $service = $this->storage 84 | ->getTransaction($uri) 85 | ->getService() 86 | ; 87 | 88 | $this->dispatcher->dispatch(new Isolated($this, $service)); 89 | $this->moveStateTo(States::ISOLATED_STATE, $service); 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * {@inheritdoc} 96 | */ 97 | public function reset(string $uri): CircuitBreaker 98 | { 99 | $service = $this->storage 100 | ->getTransaction($uri) 101 | ->getService() 102 | ; 103 | 104 | $this->dispatcher->dispatch(new Reseted($this, $service)); 105 | $this->moveStateTo(States::CLOSED_STATE, $service); 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * {@inheritdoc} 112 | */ 113 | public function moveStateTo($state, Service $service): CircuitBreaker 114 | { 115 | $this->currentPlace = $this->places[$state]; 116 | $this->currentPlace->setCircuitBreaker($this); 117 | $transaction = SimpleTransaction::createFromPlace( 118 | $this->currentPlace, 119 | $service 120 | ); 121 | 122 | $this->storage->saveTransaction($service->getURI(), $transaction); 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * @todo: refactor to remove this function in favor of moveStateTo 129 | * 130 | * @param Service $service the service 131 | */ 132 | private function initTransaction(Service $service): Transaction 133 | { 134 | if ($this->storage->hasTransaction($service->getURI())) { 135 | $transaction = $this->storage->getTransaction($service->getURI()); 136 | $this->currentPlace = $this->places[$transaction->getState()]; 137 | $this->currentPlace->setCircuitBreaker($this); 138 | } else { 139 | $transaction = SimpleTransaction::createFromPlace( 140 | $this->currentPlace, 141 | $service 142 | ); 143 | 144 | $this->dispatcher->dispatch(new Initiated($this, $service)); 145 | $this->storage->saveTransaction($service->getURI(), $transaction); 146 | } 147 | 148 | return $transaction; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/MainService.php: -------------------------------------------------------------------------------- 1 | uri = $uri; 25 | $this->parameters = $parameters; 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function getURI(): string 32 | { 33 | return $this->uri; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function getParameters(): array 40 | { 41 | return $this->parameters; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Monitors/SimpleMonitor.php: -------------------------------------------------------------------------------- 1 | report = new SimpleReport(); 16 | } 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function collect(Event $event): void 22 | { 23 | $reportEntry = new SimpleReportEntry( 24 | $event->getService(), 25 | $event->getCircuitBreaker(), 26 | get_class($event) 27 | ); 28 | 29 | $this->report->add($reportEntry); 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getReport(): Report 36 | { 37 | return $this->report; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Monitors/SimpleReport.php: -------------------------------------------------------------------------------- 1 | reportEntries = []; 15 | } 16 | 17 | /** 18 | * {@inheritdoc} 19 | */ 20 | public function add(ReportEntry $reportEntry): Report 21 | { 22 | $this->reportEntries[] = $reportEntry; 23 | 24 | return $this; 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function all(): array 31 | { 32 | return $this->reportEntries; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Monitors/SimpleReportEntry.php: -------------------------------------------------------------------------------- 1 | service = $service; 22 | $this->circuitBreaker = $circuitBreaker; 23 | $this->transition = $transition; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function getService(): Service 30 | { 31 | return $this->service; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getCircuitBreaker(): CircuitBreaker 38 | { 39 | return $this->circuitBreaker; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function getTransition(): string 46 | { 47 | return $this->transition; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Places/Closed.php: -------------------------------------------------------------------------------- 1 | client = $client; 25 | parent::__construct($failures, $timeout, 0); 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function getState(): string 32 | { 33 | return States::CLOSED_STATE; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function call(Transaction $transaction, callable $fallback): string 40 | { 41 | $service = $transaction->getService(); 42 | $storage = $this->circuitBreaker->getStorage(); 43 | 44 | if (!$this->isAllowedToRetry($transaction)) { 45 | $transaction->clearFailures(); 46 | $this->circuitBreaker->moveStateTo(States::OPEN_STATE, $service); 47 | 48 | return parent::call($transaction, $fallback); 49 | } 50 | 51 | $this->dispatch(new Tried($this->circuitBreaker, $service)); 52 | 53 | try { 54 | $response = $this->client->request($service, $this); 55 | $storage->saveTransaction($service->getUri(), $transaction); 56 | 57 | return $response; 58 | } catch (UnavailableService $exception) { 59 | $this->dispatch(new Failed($this->circuitBreaker, $service, $exception)); 60 | $transaction->incrementFailures(); 61 | $storage->saveTransaction($service->getUri(), $transaction); 62 | 63 | return parent::call($transaction, $fallback); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Places/HalfOpened.php: -------------------------------------------------------------------------------- 1 | client = $client; 30 | parent::__construct(0, $timeout, 0); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getState(): string 37 | { 38 | return States::HALF_OPEN_STATE; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function call(Transaction $transaction, callable $fallback): string 45 | { 46 | $service = $transaction->getService(); 47 | $this->dispatch(new AvailabilityChecked($this->circuitBreaker, $service)); 48 | 49 | try { 50 | $response = $this->client->request($service, $this); 51 | 52 | $this->dispatch(new Closed($this->circuitBreaker, $service)); 53 | $this->circuitBreaker->moveStateTo(States::CLOSED_STATE, $service); 54 | $transaction->clearFailures(); 55 | $this->circuitBreaker->getStorage()->saveTransaction($service->getUri(), $transaction); 56 | 57 | return $response; 58 | } catch (UnavailableService $exception) { 59 | $transaction->incrementFailures(); 60 | $this->circuitBreaker->getStorage()->saveTransaction($service->getUri(), $transaction); 61 | 62 | if (!$this->isAllowedToRetry($transaction)) { 63 | $this->dispatch(new ReOpened($this->circuitBreaker, $service)); 64 | $this->circuitBreaker->moveStateTo(States::OPEN_STATE, $service); 65 | } 66 | 67 | return parent::call($transaction, $fallback); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Places/Isolated.php: -------------------------------------------------------------------------------- 1 | useFallback($transaction, $fallback); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Places/Opened.php: -------------------------------------------------------------------------------- 1 | getService(); 34 | $this->dispatch(new OpenedEvent($this->circuitBreaker, $service)); 35 | 36 | if (!$this->haveWaitedLongEnough($transaction)) { 37 | return $this->useFallback($transaction, $fallback); 38 | } 39 | 40 | $this->circuitBreaker->moveStateTo(States::HALF_OPEN_STATE, $service); 41 | 42 | return parent::call($transaction, $fallback); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Places/PlaceHelper.php: -------------------------------------------------------------------------------- 1 | validate($failures, $timeout, $threshold); 23 | 24 | $this->failures = $failures; 25 | $this->timeout = $timeout; 26 | $this->threshold = $threshold; 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | abstract public function getState(): string; 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getFailures(): int 38 | { 39 | return $this->failures; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function getTimeout(): int 46 | { 47 | return $this->timeout; 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function getThreshold(): int 54 | { 55 | return $this->threshold; 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function setCircuitBreaker(CircuitBreaker $circuitBreaker): Place 62 | { 63 | $this->circuitBreaker = $circuitBreaker; 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function call(Transaction $transaction, callable $fallback): string 72 | { 73 | $service = $transaction->getService(); 74 | 75 | return $this->circuitBreaker->call($service->getURI(), $fallback, $service->getParameters()); 76 | } 77 | 78 | /** 79 | * @param Transaction $transaction the Transaction 80 | */ 81 | public function isAllowedToRetry(Transaction $transaction): bool 82 | { 83 | return $transaction->getFailures() < $this->failures; 84 | } 85 | 86 | /** 87 | * @param Transaction $transaction the Transaction 88 | */ 89 | public function haveWaitedLongEnough(Transaction $transaction): bool 90 | { 91 | return $transaction->getThresholdDateTime() < new DateTime(); 92 | } 93 | 94 | /** 95 | * Helper to dispatch transition events. 96 | * 97 | * @param Event $event the circuit breaker event 98 | */ 99 | protected function dispatch(Event $event): void 100 | { 101 | $this->circuitBreaker 102 | ->getDispatcher() 103 | ->dispatch($event) 104 | ; 105 | } 106 | 107 | /** 108 | * Helper to return the fallback Response. 109 | */ 110 | protected function useFallback(Transaction $transaction, callable $fallback): string 111 | { 112 | $service = $transaction->getService(); 113 | 114 | return (string) $fallback($service); 115 | } 116 | 117 | /** 118 | * Ensure the place is valid 119 | * 120 | * @throws InvalidPlace 121 | */ 122 | private function validate(int $failures, int $timeout, int $threshold): bool 123 | { 124 | $assertionsAreValid = Assert::isPositiveInteger($failures) 125 | && Assert::isPositiveInteger($timeout) 126 | && Assert::isPositiveInteger($threshold); 127 | 128 | if ($assertionsAreValid) { 129 | return true; 130 | } 131 | 132 | throw InvalidPlace::invalidSettings($failures, $timeout, $threshold); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/States.php: -------------------------------------------------------------------------------- 1 | getKey($serviceUri); 22 | 23 | $this->transactions[$key] = $transaction; 24 | 25 | return true; 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function getTransaction(string $serviceUri): Transaction 32 | { 33 | $key = $this->getKey($serviceUri); 34 | 35 | if ($this->hasTransaction($serviceUri)) { 36 | $transaction = $this->transactions[$key]; 37 | 38 | if ($transaction instanceof Transaction) { 39 | return $transaction; 40 | } 41 | } 42 | 43 | throw new TransactionNotFound(); 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function hasTransaction(string $serviceUri): bool 50 | { 51 | $key = $this->getKey($serviceUri); 52 | 53 | return array_key_exists($key, $this->transactions); 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function clear(): bool 60 | { 61 | $this->transactions = []; 62 | 63 | return true; 64 | } 65 | 66 | /** 67 | * Helper method to properly store the transaction. 68 | * 69 | * @param string $service the service URI 70 | * 71 | * @return string the transaction unique identifier 72 | */ 73 | private function getKey(string $service): string 74 | { 75 | return md5($service); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Storages/SimpleCache.php: -------------------------------------------------------------------------------- 1 | psr16Cache = $psr16Cache; 20 | } 21 | 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | public function saveTransaction(string $serviceUri, Transaction $transaction): bool 26 | { 27 | $key = $this->getKey($serviceUri); 28 | 29 | return $this->psr16Cache->set($key, $transaction); 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getTransaction(string $serviceUri): Transaction 36 | { 37 | $key = $this->getKey($serviceUri); 38 | 39 | if ($this->hasTransaction($serviceUri)) { 40 | $transaction = $this->psr16Cache->get($key); 41 | 42 | if ($transaction instanceof Transaction) { 43 | return $transaction; 44 | } 45 | } 46 | 47 | throw new TransactionNotFound(); 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function hasTransaction(string $serviceUri): bool 54 | { 55 | $key = $this->getKey($serviceUri); 56 | 57 | return $this->psr16Cache->has($key); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function clear(): bool 64 | { 65 | return $this->psr16Cache->clear(); 66 | } 67 | 68 | /** 69 | * Helper method to properly store the transaction. 70 | * 71 | * @param string $service the service URI 72 | * 73 | * @return string the transaction unique identifier 74 | */ 75 | private function getKey(string $service): string 76 | { 77 | return md5($service); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Systems/MainSystem.php: -------------------------------------------------------------------------------- 1 | places = [ 42 | $closedPlace->getState() => $closedPlace, 43 | $halfOpenPlace->getState() => $halfOpenPlace, 44 | $openPlace->getState() => $openPlace, 45 | $isolatedPlace->getState() => $isolatedPlace, 46 | ]; 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function getInitialPlace(): Place 53 | { 54 | return $this->places[States::CLOSED_STATE]; 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function getPlaces(): array 61 | { 62 | return $this->places; 63 | } 64 | 65 | /** 66 | * @throws InvalidSystem 67 | */ 68 | public static function createFromArray(array $settings, Client $client): self 69 | { 70 | if (self::validate($settings)) { 71 | $timeout = (int) $settings['timeout']; 72 | if (self::validateTimeout($timeout)) { 73 | return new self( 74 | $client, 75 | (int) $settings['failures'], 76 | $timeout, 77 | (int) $settings['stripped_timeout'], 78 | (int) $settings['threshold'] 79 | ); 80 | } 81 | 82 | throw InvalidSystem::phpTimeoutExceeded(); 83 | } 84 | 85 | throw InvalidSystem::missingSettings($settings); 86 | } 87 | 88 | private static function validate(array $settings): bool 89 | { 90 | return isset( 91 | $settings['failures'], 92 | $settings['timeout'], 93 | $settings['stripped_timeout'], 94 | $settings['threshold'] 95 | ); 96 | } 97 | 98 | private static function validateTimeout(int $timeout): bool 99 | { 100 | // @doc https://www.php.net/manual/info.configuration.php the timeout is in seconds 101 | $maxExecutionTime = ini_get('max_execution_time'); 102 | 103 | $timeoutInSeconds = (int) ($timeout / 1000); 104 | 105 | return (0 === (int) $maxExecutionTime) || ($maxExecutionTime >= $timeoutInSeconds); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Transactions/SimpleTransaction.php: -------------------------------------------------------------------------------- 1 | validate($service, $failures, $state, $threshold); 29 | 30 | $this->service = $service; 31 | $this->failures = $failures; 32 | $this->state = $state; 33 | $this->initThresholdDateTime($threshold); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function getService(): Service 40 | { 41 | return $this->service; 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function getFailures(): int 48 | { 49 | return $this->failures; 50 | } 51 | 52 | /** 53 | * {@inheritdoc} 54 | */ 55 | public function getState(): string 56 | { 57 | return $this->state; 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function getThresholdDateTime(): DateTime 64 | { 65 | return $this->thresholdDateTime; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function incrementFailures(): int 72 | { 73 | ++$this->failures; 74 | 75 | return $this->failures; 76 | } 77 | 78 | /** 79 | * {@inheritdoc} 80 | */ 81 | public function clearFailures(): bool 82 | { 83 | $this->failures = 0; 84 | 85 | return true; 86 | } 87 | 88 | /** 89 | * @throws InvalidTransaction 90 | */ 91 | public static function createFromPlace(Place $place, Service $service): self 92 | { 93 | $threshold = $place->getThreshold(); 94 | 95 | return new self( 96 | $service, 97 | 0, 98 | $place->getState(), 99 | $threshold 100 | ); 101 | } 102 | 103 | /** 104 | * Set the right DateTime from the threshold value. 105 | * 106 | * @throws Exception 107 | */ 108 | private function initThresholdDateTime(int $threshold): void 109 | { 110 | $thresholdDateTime = new DateTime(); 111 | $thresholdInSeconds = $threshold / 1_000; 112 | $thresholdDateTime->modify("+$thresholdInSeconds second"); 113 | 114 | $this->thresholdDateTime = $thresholdDateTime; 115 | } 116 | 117 | /** 118 | * @throws InvalidTransaction 119 | */ 120 | private function validate(Service $service, int $failures, string $state, int $threshold): bool 121 | { 122 | $assertionsAreValid = Assert::isAService($service) 123 | && Assert::isPositiveInteger($failures) 124 | && Assert::isString($state) 125 | && Assert::isPositiveInteger($threshold); 126 | 127 | if ($assertionsAreValid) { 128 | return true; 129 | } 130 | 131 | throw InvalidTransaction::invalidParameters($service, $failures, $state, $threshold); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Transitions.php: -------------------------------------------------------------------------------- 1 | = 0; 18 | } 19 | 20 | /** 21 | * @param mixed $value the value to evaluate 22 | */ 23 | public static function isPositiveInteger($value): bool 24 | { 25 | return self::isPositiveValue($value) && \is_int($value); 26 | } 27 | 28 | /** 29 | * @param mixed $value the value to evaluate 30 | */ 31 | public static function isURI($value): bool 32 | { 33 | return null !== $value 34 | && !is_numeric($value) 35 | && !\is_bool($value) 36 | && false !== filter_var($value, FILTER_SANITIZE_URL); 37 | } 38 | 39 | /** 40 | * @param mixed $value the value to evaluate 41 | */ 42 | public static function isString($value): bool 43 | { 44 | return !empty($value) && \is_string($value); 45 | } 46 | 47 | /** 48 | * @param object $object the object to evaluate 49 | */ 50 | public static function isAService(object $object): bool 51 | { 52 | return is_a($object, Service::class); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Utils/ErrorFormatter.php: -------------------------------------------------------------------------------- 1 |