diff --git a/README.md b/README.md index 1a695e70..caf013f8 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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` | @@ -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. diff --git a/src/XeroPHP/Application.php b/src/XeroPHP/Application.php index 675275f3..1b16f535 100644 --- a/src/XeroPHP/Application.php +++ b/src/XeroPHP/Application.php @@ -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' ] ]; @@ -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 */ @@ -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; + } } @@ -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; + } } diff --git a/src/XeroPHP/Remote/Request.php b/src/XeroPHP/Remote/Request.php index ea2aa67c..33382cc6 100644 --- a/src/XeroPHP/Remote/Request.php +++ b/src/XeroPHP/Remote/Request.php @@ -5,6 +5,7 @@ use GuzzleHttp\Psr7\Request as PsrRequest; use GuzzleHttp\Psr7\Uri; use XeroPHP\Application; +use XeroPHP\Remote\Exception\RateLimitExceededException; class Request { @@ -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(),