Skip to content

Instantly share code, notes, and snippets.

@holtkamp
Created April 9, 2018 11:54
Show Gist options
  • Save holtkamp/bae00c495f80eace6aa49d12b46f5bca to your computer and use it in GitHub Desktop.
Save holtkamp/bae00c495f80eace6aa49d12b46f5bca to your computer and use it in GitHub Desktop.
Configurable RequestThrottler useful when communicating with an throttled API
<?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