Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feed back API rate limits requests to the application. Demonstrate how to handle rate limit exceptions with Guzzle Middleware #897

Merged
merged 17 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,119 @@ See: [Signature documentation](https://developer.xero.com/documentation/webhooks
Your request to Xero may cause an error which you will want to handle. You might run into errors such as:

- `HTTP 400 Bad Request` by sending invalid data, like a malformed email address.
- `HTTP 429 Too Many Requests` by hitting the API to quickly in a short period of time.
- `HTTP 503 Rate Limit Exceeded` by hitting the API to quickly in a short period of time.
- `HTTP 400 Bad Request` by requesting a resource that does not exist.

These are just a couple of examples and you should read the official documentation to find out more about the possible errors.

### Thrown exceptions
### Rate Limit Exceptions

Xero returns header values indicating the number of calls remaining before reaching their API lmits.
https://developer.xero.com/documentation/guides/oauth2/limits/

The Application is updated following every request and you can track the number of requests remaining using the Application::getAppRateLimits() method. It returns an array with the following keys and associated integer values.

'last-api-call' // The int timestamp of the last request made to the Xero API
'app-min-limit-remaining' // The number of requests remaining for the application as a whole in the current minute. The normal limit is 10,000.
'tenant-day-limit-remaining' // The number of requests remaining for the individual tenant by the day, limit is 5,000.
'tenant-min-limit-remaining' // The number of requests remaining for the individual tenant by the minute, limit is 60.

These values can be used to decide if additional requests will throttled or sent to some message queue. For example:

``` php
// If you know the number of API calls that you intend to make.
$myExpectedApiCalls = 50;

// Before executing a statement, you could check the the rate limits.
$tenantDailyLimitRemaining = $xero->getTenantDayLimitRemining();

// If the expected number of API calls is higher than the number remaining for the tenant then do something.
if($myExpectedApiCalls > tenantDailyLimitRemaining){
// Send the calls to a queue for processing at another time
// Or throttle the calls to suit your needs.
}
```

If the Application exceeds the rate limits Xero will return an HTTP 429 Too Many Requests response. By default, this response is caught and thrown as a RateLimitException.

You can provide a more graceful method of dealing with HTTP 429 responses by using the Guzzle RetryMiddleware. You need to replace the transport client created when instantiating the Application. For example:

```php

// use GuzzleHttp\Client;
// use GuzzleHttp\HandlerStack;
// use GuzzleHttp\Middleware;
// use GuzzleHttp\RetryMiddleware;
// use Psr\Http\Message\RequestInterface;
// use Psr\Http\Message\ResponseInterface;

public function yourApplicationCreationMethod($accessToken, $tenantId): Application {

// By default the contructor creates a Guzzle Client without any handlers. Pass a third argument 'false' to skip the general client constructor.
$xero = new Application($accessToken, $tenantId, false);

// Create a new handler stack
$stack = HandlerStack::create();

// Create the MiddleWare callable, in this case with a maximum limit of 5 retries.
$stack->push($this->getRetryMiddleware(5));

// Create a new Guzzle Client
$transport = new Client([
'headers' => [
'User-Agent' => sprintf(Application::USER_AGENT_STRING, Helpers::getPackageVersion()),
'Authorization' => sprintf('Bearer %s', $accessToken),
'Xero-tenant-id' => $tenantId,
],
'handler' => $stack
]);

// Replace the default Client from the application constructor with our new Client using the RetryMiddleware
$xero->setTransport($transport);

return $xero

}

/**
* Customise the RetryMiddeware to suit your needs. Perhaps creating log messages, or making decisions about when to retry or not.
*/
protected function getRetryMiddleware(int $maxRetries): callable
{
$decider = function (
int $retries,
RequestInterface $request,
ResponseInterface $response = null
) use (
$maxRetries
): bool {
return
$retries < $maxRetries
&& null !== $response
&& \XeroPHP\Remote\Response::STATUS_TOO_MANY_REQUESTS === $response->getStatusCode();
};

$delay = function (int $retries, ResponseInterface $response): int {
if (!$response->hasHeader('Retry-After')) {
return RetryMiddleware::exponentialDelay($retries);
}

$retryAfter = $response->getHeaderLine('Retry-After');

if (!is_numeric($retryAfter)) {
$retryAfter = (new \DateTime($retryAfter))->getTimestamp() - time();
}

return (int)$retryAfter * 1000;
};

return Middleware::retry($decider, $delay);
}

```

### Thrown Exceptions

This library will parse the response Xero returns and throw an exception when it hits one of these errors. Below is a table showing the response code and corresponding exception that is thrown:

Expand All @@ -309,6 +416,7 @@ This library will parse the response Xero returns and throw an exception when it
| 403 Forbidden | `\XeroPHP\Remote\Exception\ForbiddenException` |
| 403 ReportPermissionMissingException | `\XeroPHP\Remote\Exception\ReportPermissionMissingException` |
| 404 Not Found | `\XeroPHP\Remote\Exception\NotFoundException` |
| 429 Too Many Requests | `\XeroPHP\Remote\Exception\RateLimitExceededException` |
| 500 Internal Error | `\XeroPHP\Remote\Exception\InternalErrorException` |
| 501 Not Implemented | `\XeroPHP\Remote\Exception\NotImplementedException` |
| 503 Rate Limit Exceeded | `\XeroPHP\Remote\Exception\RateLimitExceededException` |
Expand All @@ -317,7 +425,7 @@ This library will parse the response Xero returns and throw an exception when it

See: [Response codes and errors documentation](https://developer.xero.com/documentation/api/http-response-codes)

### Handling exceptions
### Handling Exceptions

To catch and handle these exceptions you can wrap the request in a try / catch block and deal with each exception as needed.

Expand Down
80 changes: 67 additions & 13 deletions src/XeroPHP/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ class Application
'xero' => [
'base_url' => 'https://api.xero.com/',
'default_content_type' => Request::CONTENT_TYPE_XML,

'core_version' => '2.0',
'payroll_version' => '1.0',
'file_version' => '1.0',
'practice_manager_version' => '3.0',
'practice_manager_version' => '3.0'
]
];

Expand All @@ -30,6 +29,11 @@ class Application
*/
protected $config;

private ?int $lastApiCall = null;
private ?int $appMinLimitRemining = null;
private ?int $tenantDayLimitRemining = null;
private ?int $tenantMinLimitRemining = null;

/**
* @var ClientInterface
*/
Expand All @@ -38,21 +42,24 @@ class Application
/**
* @param $token
* @param $tenantId
* $param $constructClient
*/
public function __construct($token, $tenantId)
public function __construct($token, $tenantId, ?bool $constructClient = true)
{
$this->config = static::$_config_defaults;

//Not sure if this is necessary, but it's one less thing to have to create outside the instance.
$transport = new Client([
'headers' => [
'User-Agent' => sprintf(static::USER_AGENT_STRING, Helpers::getPackageVersion()),
'Authorization' => sprintf('Bearer %s', $token),
'Xero-tenant-id' => $tenantId,
]
]);

$this->transport = $transport;
if($constructClient){
//Not sure if this is necessary, but it's one less thing to have to create outside the instance.
$transport = new Client([
'headers' => [
'User-Agent' => sprintf(static::USER_AGENT_STRING, Helpers::getPackageVersion()),
'Authorization' => sprintf('Bearer %s', $token),
'Xero-tenant-id' => $tenantId,
]
]);

$this->transport = $transport;
}
}


Expand Down Expand Up @@ -454,4 +461,51 @@ public function delete(Remote\Model $object)

return $object;
}

public function updateAppRateLimit(int $appMinLimitRemining)
{
$this->lastApiCall = time();
$this->appMinLimitRemining = $appMinLimitRemining;
return $this;
}

public function updateTenantRateLimits(int $tenantDayLimitRemining, int $tenantMinLimitRemining)
{
$this->lastApiCall = time();
$this->tenantDayLimitRemining = $tenantDayLimitRemining;
$this->tenantMinLimitRemining = $tenantMinLimitRemining;
return $this;
}

/**
* @return int|null Timestamp of last API call.
*/
public function getLastApiCall(): ?int
{
return $this->lastApiCall;
}

/**
* @return int|null Application call limit remaining across all tenants.
*/
public function getAppMinLimitRemining(): ?int
{
return $this->appMinLimitRemining;
}

/**
* @return int|null Tenant daily call limit remaining
*/
public function getTenantDayLimitRemining(): ?int
{
return $this->tenantDayLimitRemining;
}

/**
* @return int|null Tenant minute call limit remaining
*/
public function getTenantMinLimitRemining(): ?int
{
return $this->tenantMinLimitRemining;
}
}
17 changes: 16 additions & 1 deletion src/XeroPHP/Remote/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use GuzzleHttp\Psr7\Request as PsrRequest;
use GuzzleHttp\Psr7\Uri;
use XeroPHP\Application;
use XeroPHP\Remote\Exception\RateLimitExceededException;

class Request
{
Expand Down Expand Up @@ -108,9 +109,23 @@ public function send()

try {
$guzzleResponse = $this->app->getTransport()->send($request);
} catch (\GuzzleHttp\Exception\BadResponseException $e) {
} catch (\GuzzleHttp\Exception\BadResponseException $e) {
$guzzleResponse = $e->getResponse();
}

if($guzzleResponse->hasHeader('X-AppMinLimit-Remaining')){
$this->app->updateAppRateLimit(
$guzzleResponse->getHeader('X-AppMinLimit-Remaining')[0]
);
}

if($guzzleResponse->hasHeader('X-DayLimit-Remaining')){
$this->app->updateTenantRateLimits(
$guzzleResponse->getHeader('X-DayLimit-Remaining')[0],
$guzzleResponse->getHeader('X-MinLimit-Remaining')[0],
);
}

$this->response = new Response($this,
$guzzleResponse->getBody()->getContents(),
$guzzleResponse->getStatusCode(),
Expand Down