Created
April 9, 2018 11:54
-
-
Save holtkamp/bae00c495f80eace6aa49d12b46f5bca to your computer and use it in GitHub Desktop.
Configurable RequestThrottler useful when communicating with an throttled API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
use Illuminate\Support\Collection; | |
use Psr\Log\LoggerInterface; | |
class RequestThrottler | |
{ | |
/** | |
* @var LoggerInterface | |
*/ | |
private $logger; | |
/** | |
* @var RequestThrottlerConfiguration | |
*/ | |
private $requestThrottlerConfiguration; | |
/** | |
* Keep track of the timestamps when requests are sent to the API client using a specific request method. | |
* | |
* This allows us to respect the maximum amount of requests that are allowed per second. | |
* | |
* @var Collection[][] | |
*/ | |
private $submittedRequestTimestamps = array(); | |
public function __construct(RequestThrottlerConfiguration $requestThrottlerConfiguration, LoggerInterface $logger) | |
{ | |
$this->requestThrottlerConfiguration = $requestThrottlerConfiguration; | |
$this->logger = $logger; | |
} | |
public function ensureApiLimitsAreRespected(string $endpoint, string $requestMethod): void | |
{ | |
$endpointKey = $this->getEndpointKey($endpoint); | |
$this->submittedRequestTimestamps[$endpointKey] = $this->submittedRequestTimestamps[$endpointKey] ?? new Collection(); | |
$this->submittedRequestTimestamps[$endpointKey][$requestMethod] = $this->submittedRequestTimestamps[$endpointKey][$requestMethod] ?? new Collection(); | |
if ($limitation = $this->requestThrottlerConfiguration->getAllowedNumberOfSubmittedRequests($endpointKey, $requestMethod)) { | |
$numberOfSubmittedRequests = $this->getNumberOfSubmittedRequests($endpointKey, $requestMethod, $limitation->sampleSize); | |
if ($numberOfSubmittedRequests >= $limitation->numberOfAllowedRequests) { | |
$numberOfSecondsToSleep = $this->getNumberOfSecondsToSleep($endpointKey, $requestMethod, $limitation->sampleSize); | |
$this->logger->info('throttle HTTP {requestMethod}: sleep {numberOfSecondsToSleep} seconds since {numberOfSubmittedRequests} >= {numberOfAllowedRequests} / {sampleSizeInSeconds} seconds @ "{endpoint}" endpoint', ['requestMethod' => $requestMethod, 'numberOfSecondsToSleep' => $numberOfSecondsToSleep, 'sampleSizeInSeconds' => $limitation->sampleSize, 'numberOfSubmittedRequests' => $numberOfSubmittedRequests, 'numberOfAllowedRequests' => $limitation->numberOfAllowedRequests, 'endpoint' => $endpointKey]); | |
\sleep($numberOfSecondsToSleep); | |
$this->ensureApiLimitsAreRespected($endpointKey, $requestMethod); //Recursively invoke this function to ensure API limits are (eventually) respected | |
return; | |
} | |
$this->logger->debug('HTTP {requestMethod}: {numberOfSubmittedRequests} < {numberOfAllowedRequests} requests / {sampleSizeInSeconds} seconds @ "{endpoint}" endpoint', ['requestMethod' => $requestMethod, 'sampleSizeInSeconds' => $limitation->sampleSize, 'numberOfSubmittedRequests' => $numberOfSubmittedRequests, 'numberOfAllowedRequests' => $limitation->numberOfAllowedRequests, 'endpoint' => $endpointKey]); | |
$this->registerRequest($requestMethod, $endpointKey); | |
} | |
} | |
/** | |
* Take the starting part of the complete endpoint until the first forward slash. | |
* | |
* @param string $endpoint | |
* | |
* @return string | |
*/ | |
private function getEndpointKey(string $endpoint): string | |
{ | |
return \current(\explode('/', $endpoint)); | |
} | |
private function getNumberOfSubmittedRequests(string $endpointKey, string $requestMethod, int $numberOfConsideredSeconds): int | |
{ | |
$now = \microtime(true); | |
$thresholdTimestamp = $now - $numberOfConsideredSeconds; | |
$this->submittedRequestTimestamps[$endpointKey][$requestMethod] = $this->submittedRequestTimestamps[$endpointKey][$requestMethod]->reject( | |
function (float $requestTimestamp) use ($thresholdTimestamp): bool { | |
return $requestTimestamp < $thresholdTimestamp; //Reject all requests that were issued before the threshold | |
} | |
); | |
return $this->submittedRequestTimestamps[$endpointKey][$requestMethod]->count(); | |
} | |
private function getNumberOfSecondsToSleep(string $endpointKey, string $requestMethod, int $sampleSizeInSeconds): int | |
{ | |
$earliestRequestTimestamp = $this->submittedRequestTimestamps[$endpointKey][$requestMethod]->min(); | |
$availableAgain = $earliestRequestTimestamp + $sampleSizeInSeconds; | |
$now = \microtime(true); | |
return $availableAgain > $now ? (int) \ceil($availableAgain - $now) : 1; | |
} | |
private function registerRequest(string $requestMethod, string $endpointKey): void | |
{ | |
$this->logger->debug('register {requestMethod} request to endpoint "{endpoint}', ['requestMethod' => $requestMethod, 'endpoint' => $endpointKey]); | |
$this->submittedRequestTimestamps[$endpointKey][$requestMethod]->push(\microtime(true)); | |
} | |
} | |
class RequestThrottlerConfiguration | |
{ | |
/** | |
* @var string | |
*/ | |
private const END_POINT_DEVICE_SERVER = 'device-server'; | |
/** | |
* @var string | |
*/ | |
private const END_POINT_USER = 'user'; | |
/** | |
* @var string | |
*/ | |
private const REQUEST_METHOD_GET = 'GET'; | |
/** | |
* @var string | |
*/ | |
private const REQUEST_METHOD_POST = 'POST'; | |
/** | |
* @var string | |
*/ | |
private const REQUEST_METHOD_PUT = 'PUT'; | |
/** | |
* The Bunq API allows a limited amount request per second against their endpoints: | |
* - GET requests: 3 requests per 3 seconds | |
* - POST requests: 5 requests per 3 seconds | |
* - PUT requests: 2 requests per 3 seconds. | |
* | |
* However, from the returned error message these rates seem to differ per endpoint: | |
* - device-server/ (Too many requests. You can do a maximum of 9 GET call per 9 second to this endpoint.): | |
* - GET requests: 9 requests per 9 seconds | |
* - user/ (Too many requests. You can do a maximum of 5 calls per 5 second to this endpoint.): | |
* - POST requests: 5 requests per 5 seconds | |
* | |
* @see https://doc.bunq.com/api/1/page/errors | |
* | |
* @var array | |
*/ | |
private $limitationConfiguration; | |
public function __construct(int $additionalBuffer = 1) | |
{ | |
$this->limitationConfiguration = [ | |
self::END_POINT_DEVICE_SERVER => [ | |
self::REQUEST_METHOD_GET => new Limitation(9, 9 - $additionalBuffer), | |
], | |
self::END_POINT_USER => [ | |
self::REQUEST_METHOD_POST => new Limitation(5, 5 - $additionalBuffer), | |
], | |
self::REQUEST_METHOD_GET => new Limitation(3, 3 - $additionalBuffer), | |
self::REQUEST_METHOD_POST => new Limitation(3, 3 - $additionalBuffer), | |
self::REQUEST_METHOD_PUT => new Limitation(3, 2 - $additionalBuffer), | |
]; | |
} | |
public function getAllowedNumberOfSubmittedRequests(string $endpointKey, string $requestMethod): ?Limitation | |
{ | |
return $this->limitationConfiguration[$endpointKey][$requestMethod] | |
?? $this->limitationConfiguration[$requestMethod] | |
?? null; | |
} | |
} | |
class Limitation | |
{ | |
/** | |
* @var int | |
*/ | |
public $numberOfAllowedRequests; | |
/** | |
* The sample size in number of seconds. | |
* | |
* @var int | |
*/ | |
public $sampleSize; | |
public function __construct(int $sampleSize, int $numberOfAllowedRequests) | |
{ | |
$this->sampleSize = $sampleSize; | |
$this->numberOfAllowedRequests = $numberOfAllowedRequests; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment