diff --git a/CHANGELOG.md b/CHANGELOG.md index 0504306..a22e870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add HttpClientPool client to leverage load balancing and fallback mechanism [see the documentation](http://docs.php-http.org/en/latest/components/client-common.html) for more details. - `PluginClientFactory` to create `PluginClient` instances. - Added new option 'delay' for `RetryPlugin`. - Added new option 'decider' for `RetryPlugin`. diff --git a/spec/HttpClientPool/LeastUsedClientPoolSpec.php b/spec/HttpClientPool/LeastUsedClientPoolSpec.php new file mode 100644 index 0000000..a976c31 --- /dev/null +++ b/spec/HttpClientPool/LeastUsedClientPoolSpec.php @@ -0,0 +1,93 @@ +shouldHaveType('Http\Client\Common\HttpClientPool\LeastUsedClientPool'); + } + + public function it_is_an_http_client() + { + $this->shouldImplement('Http\Client\HttpClient'); + } + + public function it_is_an_async_http_client() + { + $this->shouldImplement('Http\Client\HttpAsyncClient'); + } + + public function it_throw_exception_with_no_client(RequestInterface $request) + { + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendAsyncRequest($request); + } + + public function it_sends_request(HttpClient $httpClient, RequestInterface $request, ResponseInterface $response) + { + $this->addHttpClient($httpClient); + $httpClient->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } + + public function it_sends_async_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request, Promise $promise) + { + $this->addHttpClient($httpAsyncClient); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + $promise->then(Argument::type('callable'), Argument::type('callable'))->willReturn($promise); + + $this->sendAsyncRequest($request)->shouldReturn($promise); + } + + public function it_throw_exception_if_no_more_enable_client(HttpClient $client, RequestInterface $request) + { + $this->addHttpClient($client); + $client->sendRequest($request)->willThrow('Http\Client\Exception\HttpException'); + + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendRequest($request); + } + + public function it_reenable_client(HttpClient $client, RequestInterface $request) + { + $this->addHttpClient(new HttpClientPoolItem($client->getWrappedObject(), 0)); + $client->sendRequest($request)->willThrow('Http\Client\Exception\HttpException'); + + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + } + + public function it_uses_the_lowest_request_client(HttpClientPoolItem $client1, HttpClientPoolItem $client2, RequestInterface $request, ResponseInterface $response) + { + if (extension_loaded('xdebug')) { + throw new SkippingException('This test fail when xdebug is enable on PHP < 7'); + } + + $this->addHttpClient($client1); + $this->addHttpClient($client2); + + $client1->getSendingRequestCount()->willReturn(10); + $client2->getSendingRequestCount()->willReturn(2); + + $client1->isDisabled()->willReturn(false); + $client2->isDisabled()->willReturn(false); + + $client1->sendRequest($request)->shouldNotBeCalled(); + $client2->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } +} diff --git a/spec/HttpClientPool/RandomClientPoolSpec.php b/spec/HttpClientPool/RandomClientPoolSpec.php new file mode 100644 index 0000000..4054d82 --- /dev/null +++ b/spec/HttpClientPool/RandomClientPoolSpec.php @@ -0,0 +1,71 @@ +shouldHaveType('Http\Client\Common\HttpClientPool\RandomClientPool'); + } + + public function it_is_an_http_client() + { + $this->shouldImplement('Http\Client\HttpClient'); + } + + public function it_is_an_async_http_client() + { + $this->shouldImplement('Http\Client\HttpAsyncClient'); + } + + public function it_throw_exception_with_no_client(RequestInterface $request) + { + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendAsyncRequest($request); + } + + public function it_sends_request(HttpClient $httpClient, RequestInterface $request, ResponseInterface $response) + { + $this->addHttpClient($httpClient); + $httpClient->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } + + public function it_sends_async_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request, Promise $promise) + { + $this->addHttpClient($httpAsyncClient); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + $promise->then(Argument::type('callable'), Argument::type('callable'))->willReturn($promise); + + $this->sendAsyncRequest($request)->shouldReturn($promise); + } + + public function it_throw_exception_if_no_more_enable_client(HttpClient $client, RequestInterface $request) + { + $this->addHttpClient($client); + $client->sendRequest($request)->willThrow('Http\Client\Exception\HttpException'); + + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendRequest($request); + } + + public function it_reenable_client(HttpClient $client, RequestInterface $request) + { + $this->addHttpClient(new HttpClientPoolItem($client->getWrappedObject(), 0)); + $client->sendRequest($request)->willThrow('Http\Client\Exception\HttpException'); + + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + } +} diff --git a/spec/HttpClientPool/RoundRobinClientPoolSpec.php b/spec/HttpClientPool/RoundRobinClientPoolSpec.php new file mode 100644 index 0000000..48c2d01 --- /dev/null +++ b/spec/HttpClientPool/RoundRobinClientPoolSpec.php @@ -0,0 +1,83 @@ +shouldHaveType('Http\Client\Common\HttpClientPool\RoundRobinClientPool'); + } + + public function it_is_an_http_client() + { + $this->shouldImplement('Http\Client\HttpClient'); + } + + public function it_is_an_async_http_client() + { + $this->shouldImplement('Http\Client\HttpAsyncClient'); + } + + public function it_throw_exception_with_no_client(RequestInterface $request) + { + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendAsyncRequest($request); + } + + public function it_sends_request(HttpClient $httpClient, RequestInterface $request, ResponseInterface $response) + { + $this->addHttpClient($httpClient); + $httpClient->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } + + public function it_sends_async_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request, Promise $promise) + { + $this->addHttpClient($httpAsyncClient); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + $promise->then(Argument::type('callable'), Argument::type('callable'))->willReturn($promise); + + $this->sendAsyncRequest($request)->shouldReturn($promise); + } + + public function it_throw_exception_if_no_more_enable_client(HttpClient $client, RequestInterface $request) + { + $this->addHttpClient($client); + $client->sendRequest($request)->willThrow('Http\Client\Exception\HttpException'); + + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Common\Exception\HttpClientNotFoundException')->duringSendRequest($request); + } + + public function it_reenable_client(HttpClient $client, RequestInterface $request) + { + $this->addHttpClient(new HttpClientPoolItem($client->getWrappedObject(), 0)); + $client->sendRequest($request)->willThrow('Http\Client\Exception\HttpException'); + + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + $this->shouldThrow('Http\Client\Exception\HttpException')->duringSendRequest($request); + } + + public function it_round_between_clients(HttpClient $client1, HttpClient $client2, RequestInterface $request, ResponseInterface $response) + { + $this->addHttpClient($client1); + $this->addHttpClient($client2); + + $client1->sendRequest($request)->willReturn($response); + $client2->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + $this->sendRequest($request)->shouldReturn($response); + } +} diff --git a/spec/HttpClientPoolItemSpec.php b/spec/HttpClientPoolItemSpec.php new file mode 100644 index 0000000..059ec8d --- /dev/null +++ b/spec/HttpClientPoolItemSpec.php @@ -0,0 +1,213 @@ +beConstructedWith($httpClient); + } + + public function it_is_an_http_client() + { + $this->shouldImplement('Http\Client\HttpClient'); + } + + public function it_is_an_async_http_client() + { + $this->shouldImplement('Http\Client\HttpAsyncClient'); + } + + public function it_sends_request(HttpClient $httpClient, RequestInterface $request, ResponseInterface $response) + { + $httpClient->sendRequest($request)->willReturn($response); + + $this->sendRequest($request)->shouldReturn($response); + } + + public function it_sends_async_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request, Promise $promise) + { + $this->beConstructedWith($httpAsyncClient); + + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + $promise->then(Argument::type('callable'), Argument::type('callable'))->willReturn($promise); + + $this->sendAsyncRequest($request)->shouldReturn($promise); + } + + public function it_disable_himself_on_send_request(HttpClient $httpClient, RequestInterface $request) + { + $exception = new TransferException(); + $httpClient->sendRequest($request)->willThrow($exception); + $this->shouldThrow($exception)->duringSendRequest($request); + $this->isDisabled()->shouldReturn(true); + $this->shouldThrow('Http\Client\Exception\RequestException')->duringSendRequest($request); + } + + public function it_disable_himself_on_send_async_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request) + { + $this->beConstructedWith($httpAsyncClient); + + $promise = new HttpRejectedPromise(new TransferException()); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + + $this->sendAsyncRequest($request)->shouldReturnAnInstanceOf('Http\Client\Promise\HttpRejectedPromise'); + $this->isDisabled()->shouldReturn(true); + $this->shouldThrow('Http\Client\Exception\RequestException')->duringSendAsyncRequest($request); + } + + public function it_reactivate_himself_on_send_request(HttpClient $httpClient, RequestInterface $request) + { + $this->beConstructedWith($httpClient, 0); + + $exception = new TransferException(); + $httpClient->sendRequest($request)->willThrow($exception); + + $this->shouldThrow($exception)->duringSendRequest($request); + $this->isDisabled()->shouldReturn(false); + $this->shouldThrow($exception)->duringSendRequest($request); + } + + public function it_reactivate_himself_on_send_async_request(HttpAsyncClient $httpAsyncClient, RequestInterface $request) + { + $this->beConstructedWith($httpAsyncClient, 0); + + $promise = new HttpRejectedPromise(new TransferException()); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + + $this->sendAsyncRequest($request)->shouldReturnAnInstanceOf('Http\Client\Promise\HttpRejectedPromise'); + $this->isDisabled()->shouldReturn(false); + $this->sendAsyncRequest($request)->shouldReturnAnInstanceOf('Http\Client\Promise\HttpRejectedPromise'); + } + + public function it_increments_request_count(HttpAsyncClient $httpAsyncClient, RequestInterface $request, ResponseInterface $response) + { + $this->beConstructedWith($httpAsyncClient, 0); + + $promise = new NotResolvingPromise($response->getWrappedObject()); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + + $this->getSendingRequestCount()->shouldReturn(0); + $this->sendAsyncRequest($request)->shouldReturn($promise); + $this->getSendingRequestCount()->shouldReturn(1); + $this->sendAsyncRequest($request)->shouldReturn($promise); + $this->getSendingRequestCount()->shouldReturn(2); + } + + public function it_decrements_request_count(HttpAsyncClient $httpAsyncClient, RequestInterface $request, ResponseInterface $response) + { + $this->beConstructedWith($httpAsyncClient, 0); + + $promise = new NotResolvingPromise($response->getWrappedObject()); + $httpAsyncClient->sendAsyncRequest($request)->willReturn($promise); + + $this->getSendingRequestCount()->shouldReturn(0); + $this->sendAsyncRequest($request)->shouldReturn($promise); + $this->getSendingRequestCount()->shouldReturn(1); + + $promise->wait(false); + + $this->getSendingRequestCount()->shouldReturn(0); + } +} + +class NotResolvingPromise implements Promise +{ + private $queue = []; + + private $state = Promise::PENDING; + + private $response; + + private $exception; + + public function __construct(ResponseInterface $response = null, Exception $exception = null) + { + $this->response = $response; + $this->exception = $exception; + } + + public function then(callable $onFulfilled = null, callable $onRejected = null) + { + $this->queue[] = [ + $onFulfilled, + $onRejected, + ]; + + return $this; + } + + public function getState() + { + return $this->state; + } + + public function wait($unwrap = true) + { + if ($this->state === Promise::FULFILLED) { + if (!$unwrap) { + return; + } + + return $this->response; + } + + if ($this->state === Promise::REJECTED) { + if (!$unwrap) { + return; + } + + throw $this->exception; + } + + while (count($this->queue) > 0) { + $callbacks = array_shift($this->queue); + + if ($this->response !== null) { + try { + $this->response = $callbacks[0]($this->response); + $this->exception = null; + } catch (Exception $exception) { + $this->response = null; + $this->exception = $exception; + } + } elseif ($this->exception !== null) { + try { + $this->response = $callbacks[1]($this->exception); + $this->exception = null; + } catch (Exception $exception) { + $this->response = null; + $this->exception = $exception; + } + } + } + + if ($this->response !== null) { + $this->state = Promise::FULFILLED; + + if ($unwrap) { + return $this->response; + } + } + + if ($this->exception !== null) { + $this->state = Promise::REJECTED; + + if ($unwrap) { + throw $this->exception; + } + } + } +} diff --git a/src/Exception/HttpClientNotFoundException.php b/src/Exception/HttpClientNotFoundException.php new file mode 100644 index 0000000..5d33f98 --- /dev/null +++ b/src/Exception/HttpClientNotFoundException.php @@ -0,0 +1,14 @@ + + */ +class HttpClientNotFoundException extends TransferException +{ +} diff --git a/src/HttpClientPool.php b/src/HttpClientPool.php new file mode 100644 index 0000000..7ac292c --- /dev/null +++ b/src/HttpClientPool.php @@ -0,0 +1,59 @@ +clientPool[] = $client; + } + + /** + * Return an http client given a specific strategy. + * + * @throws HttpClientNotFoundException When no http client has been found into the pool + * + * @return HttpClientPoolItem Return a http client that can do both sync or async + */ + abstract protected function chooseHttpClient(); + + /** + * {@inheritdoc} + */ + public function sendAsyncRequest(RequestInterface $request) + { + return $this->chooseHttpClient()->sendAsyncRequest($request); + } + + /** + * {@inheritdoc} + */ + public function sendRequest(RequestInterface $request) + { + return $this->chooseHttpClient()->sendRequest($request); + } +} diff --git a/src/HttpClientPool/LeastUsedClientPool.php b/src/HttpClientPool/LeastUsedClientPool.php new file mode 100644 index 0000000..6299cce --- /dev/null +++ b/src/HttpClientPool/LeastUsedClientPool.php @@ -0,0 +1,45 @@ + + */ +final class LeastUsedClientPool extends HttpClientPool +{ + /** + * {@inheritdoc} + */ + protected function chooseHttpClient() + { + $clientPool = array_filter($this->clientPool, function (HttpClientPoolItem $clientPoolItem) { + return !$clientPoolItem->isDisabled(); + }); + + if (0 === count($clientPool)) { + throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool'); + } + + usort($clientPool, function (HttpClientPoolItem $clientA, HttpClientPoolItem $clientB) { + if ($clientA->getSendingRequestCount() === $clientB->getSendingRequestCount()) { + return 0; + } + + if ($clientA->getSendingRequestCount() < $clientB->getSendingRequestCount()) { + return -1; + } + + return 1; + }); + + return reset($clientPool); + } +} diff --git a/src/HttpClientPool/RandomClientPool.php b/src/HttpClientPool/RandomClientPool.php new file mode 100644 index 0000000..3255f86 --- /dev/null +++ b/src/HttpClientPool/RandomClientPool.php @@ -0,0 +1,31 @@ + + */ +final class RandomClientPool extends HttpClientPool +{ + /** + * {@inheritdoc} + */ + protected function chooseHttpClient() + { + $clientPool = array_filter($this->clientPool, function (HttpClientPoolItem $clientPoolItem) { + return !$clientPoolItem->isDisabled(); + }); + + if (0 === count($clientPool)) { + throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool'); + } + + return $clientPool[array_rand($clientPool)]; + } +} diff --git a/src/HttpClientPool/RoundRobinClientPool.php b/src/HttpClientPool/RoundRobinClientPool.php new file mode 100644 index 0000000..8d8e40a --- /dev/null +++ b/src/HttpClientPool/RoundRobinClientPool.php @@ -0,0 +1,41 @@ + + */ +final class RoundRobinClientPool extends HttpClientPool +{ + /** + * {@inheritdoc} + */ + protected function chooseHttpClient() + { + $last = current($this->clientPool); + + do { + $client = next($this->clientPool); + + if (false === $client) { + $client = reset($this->clientPool); + + if (false === $client) { + throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool'); + } + } + + // Case when there is only one and the last one has been disabled + if ($last === $client && $client->isDisabled()) { + throw new HttpClientNotFoundException('Cannot choose a http client as there is no one enabled in the pool'); + } + } while ($client->isDisabled()); + + return $client; + } +} diff --git a/src/HttpClientPoolItem.php b/src/HttpClientPoolItem.php new file mode 100644 index 0000000..09cd6dd --- /dev/null +++ b/src/HttpClientPoolItem.php @@ -0,0 +1,178 @@ + + */ +class HttpClientPoolItem implements HttpClient, HttpAsyncClient +{ + /** + * @var int Number of request this client is currently sending + */ + private $sendingRequestCount = 0; + + /** + * @var \DateTime|null Time when this client has been disabled or null if enable + */ + private $disabledAt; + + /** + * @var int|null Number of seconds after this client is reenable, by default null: never reenable this client + */ + private $reenableAfter; + + /** + * @var FlexibleHttpClient A http client responding to async and sync request + */ + private $client; + + /** + * @param HttpClient|HttpAsyncClient $client + * @param null|int $reenableAfter Number of seconds after this client is reenable + */ + public function __construct($client, $reenableAfter = null) + { + $this->client = new FlexibleHttpClient($client); + $this->reenableAfter = $reenableAfter; + } + + /** + * {@inheritdoc} + */ + public function sendRequest(RequestInterface $request) + { + if ($this->isDisabled()) { + throw new Exception\RequestException('Cannot send the request as this client has been disabled', $request); + } + + try { + $this->incrementRequestCount(); + $response = $this->client->sendRequest($request); + $this->decrementRequestCount(); + } catch (Exception $e) { + $this->disable(); + $this->decrementRequestCount(); + + throw $e; + } + + return $response; + } + + /** + * {@inheritdoc} + */ + public function sendAsyncRequest(RequestInterface $request) + { + if ($this->isDisabled()) { + throw new Exception\RequestException('Cannot send the request as this client has been disabled', $request); + } + + $this->incrementRequestCount(); + + return $this->client->sendAsyncRequest($request)->then(function ($response) { + $this->decrementRequestCount(); + + return $response; + }, function ($exception) { + $this->disable(); + $this->decrementRequestCount(); + + throw $exception; + }); + } + + /** + * Whether this client is disabled or not. + * + * Will also reactivate this client if possible + * + * @internal + * + * @return bool + */ + public function isDisabled() + { + $disabledAt = $this->getDisabledAt(); + + if (null !== $this->reenableAfter && null !== $disabledAt) { + // Reenable after a certain time + $now = new \DateTime(); + + if (($now->getTimestamp() - $disabledAt->getTimestamp()) >= $this->reenableAfter) { + $this->enable(); + + return false; + } + + return true; + } + + return null !== $disabledAt; + } + + /** + * Get current number of request that is send by the underlying http client. + * + * @internal + * + * @return int + */ + public function getSendingRequestCount() + { + return $this->sendingRequestCount; + } + + /** + * Return when this client has been disabled or null if it's enabled. + * + * @return \DateTime|null + */ + private function getDisabledAt() + { + return $this->disabledAt; + } + + /** + * Increment the request count. + */ + private function incrementRequestCount() + { + ++$this->sendingRequestCount; + } + + /** + * Decrement the request count. + */ + private function decrementRequestCount() + { + --$this->sendingRequestCount; + } + + /** + * Enable the current client. + */ + private function enable() + { + $this->disabledAt = null; + } + + /** + * Disable the current client. + */ + private function disable() + { + $this->disabledAt = new \DateTime('now'); + } +}