Skip to content

Commit

Permalink
Add retry logic (#41)
Browse files Browse the repository at this point in the history
  • Loading branch information
hassansin authored and pkopac committed Jul 31, 2018
1 parent 1d607be commit f09398c
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 34 deletions.
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ composer:
@$(RUNNER) "composer $(filter-out $@,$(MAKECMDGOALS))"
dependencies:
make -s composer update -- --prefer-dist
test: dependencies
test:
$(RUNNER) "phpunit --coverage-text --coverage-html ./coverage "
php: dependencies
php:
$(RUNNER) "php $(filter-out $@,$(MAKECMDGOALS))"
cs: dependencies
cs:
$(RUNNER) "./vendor/bin/phpcs --standard=PSR2 src/"
cbf: dependencies
cbf:
$(RUNNER) "./vendor/bin/phpcbf --standard=PSR2 src/"
doc: dependencies
doc:
$(RUNNER) "./vendor/bin/phpdoc"
$(RUNNER) "./vendor/bin/phpdocmd docs/structure.xml docs --index README.md"
%:
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,21 @@ customers = $ChartMogul\Customer::all([

Learn more about this virtual package at [here](http://docs.php-http.org/en/latest/httplug/users.html).


### Rate Limits & Exponential Backoff

The library will keep retrying if the request exceeds the rate limit or if there's any network related error.
By default, the request will be retried for 20 times (approximated 15 minutes) before finally giving up.

You can change the retry count using `Configuration` object:

```php
ChartMogul\Configuration::getDefaultConfiguration()
->setAccountToken('<YOUR_ACCOUNT_TOKEN>')
->setSecretKey('<YOUR_SECRET_KEY>')
->setRetries(15); //0 disables retrying
```

## API Documentation

Find the full public API documentation [here](docs/README.md).
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"php-http/client-implementation": "^1.0",
"zendframework/zend-diactoros": "^1.3",
"php-http/guzzle6-adapter": "^1.0",
"doctrine/collections": "^1.3"
"doctrine/collections": "^1.3",
"stechstudio/backoff": "^1.0"

},
"require-dev": {
"php-http/mock-client": "^0.3",
Expand Down
30 changes: 29 additions & 1 deletion src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

namespace ChartMogul;

const DEFAULT_MAX_RETRIES = 20;

/**
*
* @codeCoverageIgnore
Expand All @@ -23,16 +25,22 @@ class Configuration
* @var string
*/
private $secretKey = '';
/**
* @var int
* maximum retry attempts
*/
private $retry = DEFAULT_MAX_RETRIES;

/**
* Creates new config object from accountToken and secretKey
* @param string $accountToken
* @param string $secretKey
*/
public function __construct($accountToken = '', $secretKey = '')
public function __construct($accountToken = '', $secretKey = '', $retries = DEFAULT_MAX_RETRIES)
{
$this->accountToken = $accountToken;
$this->secretKey = $secretKey;
$this->retries = $retries;
}

/**
Expand Down Expand Up @@ -75,6 +83,26 @@ public function setSecretKey($secretKey)
return $this;
}

/**
* Get retries
* @return int
*/
public function getRetries()
{
return $this->retries;
}

/**
* Set max retries
* @param int $retries
* @return self
*/
public function setRetries($retries)
{
$this->retries = $retries;
return $this;
}

/**
* Set Default Config object. Default config object is used when no config object is passed during resource call
* @return Configuration
Expand Down
13 changes: 10 additions & 3 deletions src/Http/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ public function getBasicAuthHeader()
);
}

protected function sendWithRetry(Request $request)
{
$backoff = new Retry($this->config->getRetries());
$response = $backoff->retry(function () use ($request) {
return $this->client->sendRequest($request);
});
return $this->handleResponse($response);
}

public function send($path = '', $method = 'GET', $data = [])
{

Expand All @@ -171,9 +180,7 @@ public function send($path = '', $method = 'GET', $data = [])
$request->getBody()->write(json_encode($data));
}

$response = $this->client->sendRequest($request);

return $this->handleResponse($response);
return $this->sendWithRetry($request);
}

/**
Expand Down
56 changes: 56 additions & 0 deletions src/Http/Retry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
namespace ChartMogul\Http;

use Psr\Http\Message\ResponseInterface;
use STS\Backoff\Backoff;

class ExponentialStrategy extends \STS\Backoff\Strategies\ExponentialStrategy
{
// min wait time
protected $base = 1000;
}

class Retry
{

private $retries;

public function __construct($retries)
{
$this->retries = $retries;
}

private function retryHTTPStatus($status)
{
return $status == 429 || ($status >= 500 && $status < 600);
}

protected function shouldRetry($attempt, $maxAttempts, ResponseInterface $response = null, \Exception $ex = null)
{
if ($attempt >= $maxAttempts && ! is_null($ex)) {
throw $ex;
}

if (!is_null($response)) {
return $attempt < $maxAttempts && $this->retryHTTPStatus($response->getStatusCode());
}

// retry on all network related errors
return $attempt < $maxAttempts && $ex instanceof \Http\Client\Exception\NetworkException;
}

public function retry($callback)
{
if ($this->retries === 0) {
return $callback();
}
$backoff = new Backoff($this->retries, new ExponentialStrategy(), 60*1000, true);
$backoff->setDecider($this);
return $backoff->run($callback);
}

public function __invoke($attempt, $maxAttempts, $response, $exception)
{
return $this->shouldRetry($attempt, $maxAttempts, $response, $exception);
}
}
100 changes: 76 additions & 24 deletions tests/Unit/Http/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

use ChartMogul\Http\Client;
use ChartMogul\Exceptions\ChartMogulException;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Response;
use Zend\Diactoros\Request;

class ClientTest extends \PHPUnit\Framework\TestCase
{
Expand Down Expand Up @@ -102,6 +105,35 @@ public function testHandleResponseExceptions($status, $exception)
}


private function getMockClient($retries, $statuses, $exceptions = []){
$mock = $this->getMockBuilder(Client::class)
->disableOriginalConstructor()
->setMethods(['getBasicAuthHeader', 'getUserAgent'])
->getMock();

$mock->expects($this->once())
->method('getBasicAuthHeader')
->willReturn('auth');

$mock->expects($this->once())
->method('getUserAgent')
->willReturn('agent');


$mockClient = new \Http\Mock\Client();
$mock->setHttpClient($mockClient);
$mock->setConfiguration(\ChartMogul\Configuration::getDefaultConfiguration()->setRetries($retries));
$stream = Psr7\stream_for('{}');
foreach($statuses as $status) {
$response = new Response($status, ['Content-Type' => 'application/json'], $stream);
$mockClient->addResponse($response);
}
foreach($exceptions as $exception) {
$mockClient->addException($exception);
}
return [$mock, $mockClient];
}

public function providerSend()
{
return [
Expand All @@ -123,33 +155,11 @@ public function providerSend()
*/
public function testSend($path, $method, $data, $target, $rBody)
{
$mock = $this->getMockBuilder(Client::class)
->disableOriginalConstructor()
->setMethods(['getBasicAuthHeader', 'getUserAgent', 'handleResponse'])
->getMock();

$mock->expects($this->once())
->method('getBasicAuthHeader')
->willReturn('auth');

$mock->expects($this->once())
->method('getUserAgent')
->willReturn('agent');

$response = $this->getMockBuilder('Psr\Http\Message\ResponseInterface')->getMock();

$mock->expects($this->once())
->method('handleResponse')
->willReturn($response);

$mockClient = new \Http\Mock\Client();
$mock->setHttpClient($mockClient);
$mockClient->addResponse($response);

list($mock, $mockClient) = $this->getMockClient(20, [200]);

$returnedResponse = $mock->send($path, $method, $data);
$request= $mockClient->getRequests()[0];
$this->assertSame($response, $returnedResponse);
$this->assertSame($returnedResponse, []);
$this->assertEquals($request->getHeader('user-agent'), ['agent']);
$this->assertEquals($request->getHeader('Authorization'), ['auth']);
$this->assertEquals($request->getHeader('content-type'), ['application/json']);
Expand All @@ -158,4 +168,46 @@ public function testSend($path, $method, $data, $target, $rBody)
$request->getBody()->rewind();
$this->assertEquals($request->getBody()->getContents(), $rBody);
}

/**
* @expectedException ChartMogul\Exceptions\ChartMogulException
*/
public function testNoRetry()
{
list($mock, $mockClient) = $this->getMockClient(0, [500]);

$returnedResponse = $mock->send('', 'GET', []);
$this->assertEquals(count($mockClient->getRequests()), 1);
}

public function testRetryHTTP()
{
list($mock, $mockClient) = $this->getMockClient(10, [500, 429, 200]);

$returnedResponse = $mock->send('', 'GET', []);
$this->assertEquals(count($mockClient->getRequests()), 3);
}
public function testRetryNetworkError()
{
list($mock, $mockClient) = $this->getMockClient(10, [200], [
new \Http\Client\Exception\NetworkException("some error", new Request()),
]);

$returnedResponse = $mock->send('', 'GET', []);
$this->assertEquals(count($mockClient->getRequests()), 2);
}
/**
* @expectedException \Http\Client\Exception\NetworkException
*/
public function testRetryMaxAttemptReached()
{
list($mock, $mockClient) = $this->getMockClient(1, [200], [
new \Http\Client\Exception\NetworkException("some error", new Request()),
new \Http\Client\Exception\NetworkException("some error", new Request()),
]);


$returnedResponse = $mock->send('', 'GET', []);
$this->assertEquals(count($mockClient->getRequests()), 1);
}
}

0 comments on commit f09398c

Please sign in to comment.