diff --git a/.travis.yml b/.travis.yml index ffbec4e..6d8b72f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ language: php php: - 5.5 - 5.6 + - 7.0 before_script: - composer self-update diff --git a/README.md b/README.md index 33790eb..62f1433 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ compatible autoloader. ```php // 1. Make a rate limiter with limit 3 attempts in 10 minutes $cacheAdapter = new DesarrollaCacheAdapter((new DesarrollaCacheFactory())->make()); -$ratelimiter = new RateLimiter(new ThrottlerFactory(), new HydratorFactory(), $cacheAdapter, 3, 600); +$settings = new ElasticWindowSettings(3, 600); +$ratelimiter = new RateLimiter(new ThrottlerFactory($cacheAdapter), new HydratorFactory(), $settings); // 2. Get a throttler for path /login $loginThrottler = $ratelimiter->get('/login'); @@ -145,6 +146,67 @@ class MyHydratorFactory implements FactoryInterface } ``` +## Throttler types + +### Elastic Window +An elastic window throttler will allow X requests in Y seconds. Any further access attempts will be counted, but return false as status. Note that the window will be extended with Y seconds on every hit. This means there need to be no hits during Y seconds for the counter to be reset to 0. + +See [Overview example](#overview) for instantiation. + +### Time-based throttlers +All the following throttlers use time functions, thus needing a different factory for construction: + +```php +$cacheAdapter = new DesarrollaCacheAdapter((new DesarrollaCacheFactory())->make()); +$timeAdapter = new PhpTimeAdapter(); + +$throttlerFactory = new TimeAwareThrottlerFactory($cacheAdapter, $timeAdapter); +$hydratorFactory = new HydratorFactory(); + +//$settings = ... +$ratelimiter = new RateLimiter($throttlerFactory, $hydratorFactory, $settings); +``` + +#### Fixed Window +A fixed window throttler will allow X requests in the Y seconds since the first request. Any further access attempts will be counted, but return false as status. The window will not be extended at all. + +```php +// Make a rate limiter with limit 120 attempts per minute +$settings = new FixedWindowSettings(120, 60); +``` + +#### Moving Window +A moving window throttler will allow X requests during the previous Y seconds. Any further access attempts will be counted, but return false as status. The window is never extended beyond Y seconds. + +```php +// Make a rate limiter with limit 120 attempts per minute +$settings = new MovingWindowSettings(120, 60); +``` + +#### Leaky Bucket +A [leaky bucket](https://en.wikipedia.org/wiki/Leaky_bucket) throttler will allow X requests divided over time Y. + +Any access attempts past the threshold T (default: 0) will be delayed by Y / (X - T) + +`access()` will return false if delayed, `hit()` will return the number of milliseconds waited + +__Note: Time limit for this throttler is in milliseconds, where it is seconds for the other throttler types!__ + +```php +// Make a rate limiter with limit 120 attempts per minute, start delaying after 30 requests +$settings = new LeakyBucketSettings(120, 60000, 30); +``` + +#### Retrial Queue +The retrial queue encapsulates another throttler. +When this throttler receives a hit which would fail on the internal throttler, +the request is delayed until the internal throttler has capacity again. + +```php +// Make a leaky bucket ratelimiter which delays any overflow +$settings = new RetrialQueueSettings(new LeakyBucketSettings(120, 60000, 120)); +``` + ## Author Krishnaprasad MG [@sunspikes] diff --git a/phpunit.xml b/phpunit.xml index 40e023b..9ab304f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,4 +14,7 @@ ./tests/ + + + diff --git a/src/Cache/Factory/DesarrollaCacheFactory.php b/src/Cache/Factory/DesarrollaCacheFactory.php index f28cd77..ed28969 100644 --- a/src/Cache/Factory/DesarrollaCacheFactory.php +++ b/src/Cache/Factory/DesarrollaCacheFactory.php @@ -199,10 +199,10 @@ protected function createMemcacheDriver() { $server = null; - if (isset($this->config['servers'])) { + if (isset($this->config['memcache']['servers'])) { $server = new \Memcache(); - foreach ($this->config['servers'] as $host) { + foreach ($this->config['memcache']['servers'] as $host) { $server->addserver($host); } } diff --git a/src/RateLimiter.php b/src/RateLimiter.php index f94b5f3..0a07646 100644 --- a/src/RateLimiter.php +++ b/src/RateLimiter.php @@ -25,33 +25,19 @@ namespace Sunspikes\Ratelimit; -use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; +use Sunspikes\Ratelimit\Throttle\Entity\Data; +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; use Sunspikes\Ratelimit\Throttle\Throttler\ThrottlerInterface; use Sunspikes\Ratelimit\Throttle\Factory\FactoryInterface as ThrottlerFactoryInterface; use Sunspikes\Ratelimit\Throttle\Hydrator\FactoryInterface as HydratorFactoryInterface; -class RateLimiter +class RateLimiter implements RateLimiterInterface { - /** - * @var CacheAdapterInterface - */ - protected $adapter; - /** * @var ThrottlerInterface[] */ protected $throttlers; - /** - * @var int - */ - protected $limit; - - /** - * @var int - */ - protected $ttl; - /** * @var ThrottlerFactoryInterface */ @@ -62,54 +48,61 @@ class RateLimiter */ protected $hydratorFactory; + /** + * @var ThrottleSettingsInterface + */ + private $defaultSettings; + /** * @param ThrottlerFactoryInterface $throttlerFactory * @param HydratorFactoryInterface $hydratorFactory - * @param CacheAdapterInterface $cacheAdapter - * @param int $limit - * @param int $ttl + * @param ThrottleSettingsInterface $defaultSettings */ public function __construct( ThrottlerFactoryInterface $throttlerFactory, HydratorFactoryInterface $hydratorFactory, - CacheAdapterInterface $cacheAdapter, - $limit, - $ttl + ThrottleSettingsInterface $defaultSettings ) { $this->throttlerFactory = $throttlerFactory; $this->hydratorFactory = $hydratorFactory; - $this->adapter = $cacheAdapter; - $this->limit = $limit; - $this->ttl = $ttl; + $this->defaultSettings = $defaultSettings; } /** - * Build the throttler for given data - * - * @param mixed $data - * @param int|null $limit - * @param int|null $ttl - * - * @return mixed - * - * @throws \InvalidArgumentException + * @inheritdoc */ - public function get($data, $limit = null, $ttl = null) + public function get($data, ThrottleSettingsInterface $settings = null) { if (empty($data)) { throw new \InvalidArgumentException('Invalid data, please check the data.'); } - $limit = null === $limit ? $this->limit : $limit; - $ttl = null === $ttl ? $this->ttl : $ttl; + $object = $this->hydratorFactory->make($data)->hydrate($data); - // Create the data object - $dataObject = $this->hydratorFactory->make($data)->hydrate($data, $limit, $ttl); + if (!isset($this->throttlers[$object->getKey()])) { + $this->throttlers[$object->getKey()] = $this->createThrottler($object, $settings); + } - if (!isset($this->throttlers[$dataObject->getKey()])) { - $this->throttlers[$dataObject->getKey()] = $this->throttlerFactory->make($dataObject, $this->adapter); + return $this->throttlers[$object->getKey()]; + } + + /** + * @param Data $object + * @param ThrottleSettingsInterface|null $settings + * + * @return ThrottlerInterface + */ + private function createThrottler(Data $object, ThrottleSettingsInterface $settings = null) + { + if (null === $settings) { + $settings = $this->defaultSettings; + } else { + try { + $settings = $this->defaultSettings->merge($settings); + } catch (\InvalidArgumentException $exception) { + } } - return $this->throttlers[$dataObject->getKey()]; + return $this->throttlerFactory->make($object, $settings); } } diff --git a/src/RateLimiterInterface.php b/src/RateLimiterInterface.php new file mode 100644 index 0000000..836ff21 --- /dev/null +++ b/src/RateLimiterInterface.php @@ -0,0 +1,44 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit; + +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; +use Sunspikes\Ratelimit\Throttle\Throttler\ThrottlerInterface; + +interface RateLimiterInterface +{ + /** + * Return a throttler for given data and settings + * + * @param mixed $data + * @param ThrottleSettingsInterface|null $throttlerSettings + * + * @return ThrottlerInterface + * + * @throws \InvalidArgumentException + */ + public function get($data, ThrottleSettingsInterface $throttlerSettings = null); +} diff --git a/src/Throttle/Entity/Data.php b/src/Throttle/Entity/Data.php index da00d14..b746dd6 100644 --- a/src/Throttle/Entity/Data.php +++ b/src/Throttle/Entity/Data.php @@ -25,27 +25,24 @@ namespace Sunspikes\Ratelimit\Throttle\Entity; -class Data +final class Data { - /* @var string */ - protected $data; - /* @var int */ - protected $limit; - /* @var int */ - protected $ttl; - /* @var string */ - protected $key; + /** + * @var string + */ + private $data; + + /** + * @var string + */ + private $key; /** * @param string $data - * @param int $limit - * @param int $ttl */ - public function __construct($data, $limit, $ttl) + public function __construct($data) { $this->data = $data; - $this->limit = $limit; - $this->ttl = $ttl; } /** @@ -58,26 +55,6 @@ public function getData() return $this->data; } - /** - * Get limit - * - * @return int - */ - public function getLimit() - { - return $this->limit; - } - - /** - * Get TTL - * - * @return int - */ - public function getTtl() - { - return $this->ttl; - } - /** * Get key * diff --git a/src/Throttle/Factory/FactoryInterface.php b/src/Throttle/Factory/FactoryInterface.php index 1cac98a..fb18659 100644 --- a/src/Throttle/Factory/FactoryInterface.php +++ b/src/Throttle/Factory/FactoryInterface.php @@ -25,18 +25,20 @@ namespace Sunspikes\Ratelimit\Throttle\Factory; -use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; use Sunspikes\Ratelimit\Throttle\Entity\Data; +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; +use Sunspikes\Ratelimit\Throttle\Throttler\ThrottlerInterface; interface FactoryInterface { /** * Create the throttler * - * @param Data $data - * @param CacheAdapterInterface $cache + * @param Data $data + * @param ThrottleSettingsInterface $settings * - * @return \Sunspikes\Ratelimit\Throttle\Throttler\ThrottlerInterface + * @return ThrottlerInterface + * @throws \InvalidArgumentException */ - public function make(Data $data, CacheAdapterInterface $cache); + public function make(Data $data, ThrottleSettingsInterface $settings); } diff --git a/src/Throttle/Factory/ThrottlerFactory.php b/src/Throttle/Factory/ThrottlerFactory.php index dfa4998..66187ba 100644 --- a/src/Throttle/Factory/ThrottlerFactory.php +++ b/src/Throttle/Factory/ThrottlerFactory.php @@ -27,22 +27,56 @@ use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; use Sunspikes\Ratelimit\Throttle\Entity\Data; -use Sunspikes\Ratelimit\Throttle\Throttler\CacheThrottler; +use Sunspikes\Ratelimit\Throttle\Settings\ElasticWindowSettings; +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; +use Sunspikes\Ratelimit\Throttle\Throttler\ElasticWindowThrottler; class ThrottlerFactory implements FactoryInterface { + /** + * @var CacheAdapterInterface + */ + protected $cacheAdapter; + + /** + * @param CacheAdapterInterface $cacheAdapter + */ + public function __construct(CacheAdapterInterface $cacheAdapter) + { + $this->cacheAdapter = $cacheAdapter; + } + /** * @inheritdoc */ - public function make(Data $data, CacheAdapterInterface $cache) + public function make(Data $data, ThrottleSettingsInterface $settings) { - $throttler = new CacheThrottler( - $cache, - (string) $data->getKey(), - (int) $data->getLimit(), - (int) $data->getTtl() - ); + if (!$settings->isValid()) { + throw new \InvalidArgumentException('Provided throttler settings not valid'); + } + + return $this->createThrottler($data, $settings); + } + + /** + * @param Data $data + * @param ThrottleSettingsInterface $settings + * + * @return ElasticWindowThrottler + */ + protected function createThrottler(Data $data, ThrottleSettingsInterface $settings) + { + if ($settings instanceof ElasticWindowSettings) { + return new ElasticWindowThrottler( + $this->cacheAdapter, + $data->getKey(), + $settings->getLimit(), + $settings->getTime() + ); + } - return $throttler; + throw new \InvalidArgumentException( + sprintf('Unable to create throttler for %s settings', get_class($settings)) + ); } } diff --git a/src/Throttle/Factory/TimeAwareThrottlerFactory.php b/src/Throttle/Factory/TimeAwareThrottlerFactory.php new file mode 100644 index 0000000..1b9f29e --- /dev/null +++ b/src/Throttle/Factory/TimeAwareThrottlerFactory.php @@ -0,0 +1,118 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Factory; + +use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; +use Sunspikes\Ratelimit\Throttle\Entity\Data; +use Sunspikes\Ratelimit\Throttle\Settings\FixedWindowSettings; +use Sunspikes\Ratelimit\Throttle\Settings\LeakyBucketSettings; +use Sunspikes\Ratelimit\Throttle\Settings\MovingWindowSettings; +use Sunspikes\Ratelimit\Throttle\Settings\RetrialQueueSettings; +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; +use Sunspikes\Ratelimit\Throttle\Throttler\FixedWindowThrottler; +use Sunspikes\Ratelimit\Throttle\Throttler\LeakyBucketThrottler; +use Sunspikes\Ratelimit\Throttle\Throttler\MovingWindowThrottler; +use Sunspikes\Ratelimit\Throttle\Throttler\RetrialQueueThrottler; +use Sunspikes\Ratelimit\Throttle\Throttler\ThrottlerInterface; +use Sunspikes\Ratelimit\Time\TimeAdapterInterface; + +class TimeAwareThrottlerFactory extends ThrottlerFactory +{ + /** + * @var TimeAdapterInterface + */ + private $timeAdapter; + + /** + * @param CacheAdapterInterface $cacheAdapter + * @param TimeAdapterInterface $timeAdapter + */ + public function __construct(CacheAdapterInterface $cacheAdapter, TimeAdapterInterface $timeAdapter) + { + parent::__construct($cacheAdapter); + $this->timeAdapter = $timeAdapter; + } + + /** + * @inheritdoc + */ + protected function createThrottler(Data $data, ThrottleSettingsInterface $settings) + { + if ($settings instanceof RetrialQueueSettings) { + return new RetrialQueueThrottler( + $this->createNestableController($data, $settings->getInternalThrottlerSettings()), + $this->timeAdapter + ); + } + + return $this->createNestableController($data, $settings); + } + + /** + * @param Data $data + * @param ThrottleSettingsInterface $settings + * + * @return ThrottlerInterface + */ + private function createNestableController(Data $data, ThrottleSettingsInterface $settings) + { + if ($settings instanceof LeakyBucketSettings) { + return new LeakyBucketThrottler( + $this->cacheAdapter, + $this->timeAdapter, + $data->getKey(), + $settings->getTokenLimit(), + $settings->getTimeLimit(), + $settings->getThreshold(), + $settings->getCacheTtl() + ); + } + + if ($settings instanceof MovingWindowSettings) { + return new MovingWindowThrottler( + $this->cacheAdapter, + $this->timeAdapter, + $data->getKey(), + $settings->getHitLimit(), + $settings->getTimeLimit(), + $settings->getCacheTtl() + ); + } + + if ($settings instanceof FixedWindowSettings) { + return new FixedWindowThrottler( + $this->cacheAdapter, + $this->timeAdapter, + $data->getKey(), + $settings->getHitLimit(), + $settings->getTimeLimit(), + $settings->getCacheTtl() + ); + } + + return parent::createThrottler($data, $settings); + } +} diff --git a/src/Throttle/Hydrator/ArrayHydrator.php b/src/Throttle/Hydrator/ArrayHydrator.php index 38b06a2..b9576e8 100644 --- a/src/Throttle/Hydrator/ArrayHydrator.php +++ b/src/Throttle/Hydrator/ArrayHydrator.php @@ -32,10 +32,10 @@ class ArrayHydrator implements DataHydratorInterface /** * @inheritdoc */ - public function hydrate($data, $limit, $ttl) + public function hydrate($data) { $string = implode('', $data); - return new Data($string, $limit, $ttl); + return new Data($string); } } diff --git a/src/Throttle/Hydrator/DataHydratorInterface.php b/src/Throttle/Hydrator/DataHydratorInterface.php index d9f0238..2574761 100644 --- a/src/Throttle/Hydrator/DataHydratorInterface.php +++ b/src/Throttle/Hydrator/DataHydratorInterface.php @@ -28,13 +28,11 @@ interface DataHydratorInterface { /** - * Hydrate the given data + * Hydrate a data object with the given data * * @param mixed $data - * @param int $limit - * @param int $ttl * * @return \Sunspikes\Ratelimit\Throttle\Entity\Data */ - public function hydrate($data, $limit, $ttl); + public function hydrate($data); } diff --git a/src/Throttle/Hydrator/StringHydrator.php b/src/Throttle/Hydrator/StringHydrator.php index 50da1e9..10c154d 100644 --- a/src/Throttle/Hydrator/StringHydrator.php +++ b/src/Throttle/Hydrator/StringHydrator.php @@ -32,8 +32,8 @@ class StringHydrator implements DataHydratorInterface /** * @inheritdoc */ - public function hydrate($data, $limit, $ttl) + public function hydrate($data) { - return new Data($data, $limit, $ttl); + return new Data($data); } } diff --git a/src/Throttle/Settings/AbstractWindowSettings.php b/src/Throttle/Settings/AbstractWindowSettings.php new file mode 100644 index 0000000..34229b4 --- /dev/null +++ b/src/Throttle/Settings/AbstractWindowSettings.php @@ -0,0 +1,109 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +abstract class AbstractWindowSettings implements ThrottleSettingsInterface +{ + /** + * @var int|null + */ + private $hitLimit; + + /** + * @var int|null + */ + private $timeLimit; + + /** + * @var int|null + */ + private $cacheTtl; + + /** + * @param int|null $tokenLimit + * @param int|null $timeLimit In seconds + * @param int|null $cacheTtl In seconds + */ + public function __construct($tokenLimit = null, $timeLimit = null, $cacheTtl = null) + { + $this->hitLimit = $tokenLimit; + $this->timeLimit = $timeLimit; + $this->cacheTtl = $cacheTtl; + } + + /** + * @inheritdoc + */ + public function merge(ThrottleSettingsInterface $settings) + { + if (!$settings instanceof static) { + throw new \InvalidArgumentException( + sprintf('Unable to merge %s into %s', get_class($settings), get_class($this)) + ); + } + + return new static( + null === $settings->getHitLimit() ? $this->hitLimit : $settings->getHitLimit(), + null === $settings->getTimeLimit() ? $this->timeLimit : $settings->getTimeLimit(), + null === $settings->getCacheTtl() ? $this->cacheTtl : $settings->getCacheTtl() + ); + } + + /** + * @inheritdoc + */ + public function isValid() + { + return + null !== $this->hitLimit && + null !== $this->timeLimit && + 0 !== $this->timeLimit; + } + + /** + * @return int|null + */ + public function getHitLimit() + { + return $this->hitLimit; + } + + /** + * @return int|null + */ + public function getTimeLimit() + { + return $this->timeLimit; + } + + /** + * @return int|null + */ + public function getCacheTtl() + { + return $this->cacheTtl; + } +} diff --git a/src/Throttle/Settings/ElasticWindowSettings.php b/src/Throttle/Settings/ElasticWindowSettings.php new file mode 100644 index 0000000..02a6c5b --- /dev/null +++ b/src/Throttle/Settings/ElasticWindowSettings.php @@ -0,0 +1,90 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +final class ElasticWindowSettings implements ThrottleSettingsInterface +{ + /** + * @var int|null + */ + private $limit; + + /** + * @var int|null + */ + private $time; + + /** + * @param int|null $limit + * @param int|null $time + */ + public function __construct($limit = null, $time = null) + { + $this->limit = $limit; + $this->time = $time; + } + + /** + * @inheritdoc + */ + public function merge(ThrottleSettingsInterface $settings) + { + if (!$settings instanceof self) { + throw new \InvalidArgumentException( + sprintf('Unable to merge %s into %s', get_class($settings), get_class($this)) + ); + } + + return new self( + null === $settings->getLimit() ? $this->limit : $settings->getLimit(), + null === $settings->getTime() ? $this->time : $settings->getTime() + ); + } + + /** + * @inheritdoc + */ + public function isValid() + { + return null !== $this->limit && null !== $this->time; + } + + /** + * @return int|null + */ + public function getLimit() + { + return $this->limit; + } + + /** + * @return int|null + */ + public function getTime() + { + return $this->time; + } +} diff --git a/src/Throttle/Settings/FixedWindowSettings.php b/src/Throttle/Settings/FixedWindowSettings.php new file mode 100644 index 0000000..74743e6 --- /dev/null +++ b/src/Throttle/Settings/FixedWindowSettings.php @@ -0,0 +1,30 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +final class FixedWindowSettings extends AbstractWindowSettings +{ +} diff --git a/src/Throttle/Settings/LeakyBucketSettings.php b/src/Throttle/Settings/LeakyBucketSettings.php new file mode 100644 index 0000000..611a322 --- /dev/null +++ b/src/Throttle/Settings/LeakyBucketSettings.php @@ -0,0 +1,125 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +final class LeakyBucketSettings implements ThrottleSettingsInterface +{ + /** + * @var int|null + */ + private $tokenLimit; + + /** + * @var int|null + */ + private $timeLimit; + + /** + * @var int|null + */ + private $threshold; + + /** + * @var int|null + */ + private $cacheTtl; + + /** + * @param int|null $tokenLimit + * @param int|null $timeLimit In milliseconds + * @param int|null $threshold + * @param int|null $cacheTtl In seconds + */ + public function __construct($tokenLimit = null, $timeLimit = null, $threshold = null, $cacheTtl = null) + { + $this->tokenLimit = $tokenLimit; + $this->timeLimit = $timeLimit; + $this->threshold = $threshold; + $this->cacheTtl = $cacheTtl; + } + + /** + * @inheritdoc + */ + public function merge(ThrottleSettingsInterface $settings) + { + if (!$settings instanceof self) { + throw new \InvalidArgumentException( + sprintf('Unable to merge %s into %s', get_class($settings), get_class($this)) + ); + } + + return new self( + null === $settings->getTokenLimit() ? $this->tokenLimit : $settings->getTokenLimit(), + null === $settings->getTimeLimit() ? $this->timeLimit : $settings->getTimeLimit(), + null === $settings->getThreshold() ? $this->threshold : $settings->getThreshold(), + null === $settings->getCacheTtl() ? $this->cacheTtl : $settings->getCacheTtl() + ); + } + + /** + * @inheritdoc + */ + public function isValid() + { + return + null !== $this->tokenLimit && + null !== $this->timeLimit && + 0 !== $this->timeLimit; + } + + /** + * @return int|null + */ + public function getTokenLimit() + { + return $this->tokenLimit; + } + + /** + * @return int|null + */ + public function getTimeLimit() + { + return $this->timeLimit; + } + + /** + * @return int|null + */ + public function getThreshold() + { + return $this->threshold; + } + + /** + * @return int|null + */ + public function getCacheTtl() + { + return $this->cacheTtl; + } +} diff --git a/src/Throttle/Settings/MovingWindowSettings.php b/src/Throttle/Settings/MovingWindowSettings.php new file mode 100644 index 0000000..009301a --- /dev/null +++ b/src/Throttle/Settings/MovingWindowSettings.php @@ -0,0 +1,30 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +final class MovingWindowSettings extends AbstractWindowSettings +{ +} diff --git a/src/Throttle/Settings/RetrialQueueSettings.php b/src/Throttle/Settings/RetrialQueueSettings.php new file mode 100644 index 0000000..817d38b --- /dev/null +++ b/src/Throttle/Settings/RetrialQueueSettings.php @@ -0,0 +1,72 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +final class RetrialQueueSettings implements ThrottleSettingsInterface +{ + /** + * @var ThrottleSettingsInterface + */ + private $internalThrottlerSettings; + + /** + * @param ThrottleSettingsInterface $internalThrottlerSettings + */ + public function __construct(ThrottleSettingsInterface $internalThrottlerSettings) + { + $this->internalThrottlerSettings = $internalThrottlerSettings; + } + + /** + * @inheritdoc + */ + public function merge(ThrottleSettingsInterface $settings) + { + if (!$settings instanceof self) { + throw new \InvalidArgumentException( + sprintf('Unable to merge %s into %s', get_class($settings), get_class($this)) + ); + } + + return new self($this->internalThrottlerSettings->merge($settings->getInternalThrottlerSettings())); + } + + /** + * @inheritdoc + */ + public function isValid() + { + return $this->internalThrottlerSettings->isValid(); + } + + /** + * @return ThrottleSettingsInterface + */ + public function getInternalThrottlerSettings() + { + return $this->internalThrottlerSettings; + } +} diff --git a/src/Throttle/Settings/ThrottleSettingsInterface.php b/src/Throttle/Settings/ThrottleSettingsInterface.php new file mode 100644 index 0000000..baf3ad3 --- /dev/null +++ b/src/Throttle/Settings/ThrottleSettingsInterface.php @@ -0,0 +1,41 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Settings; + +interface ThrottleSettingsInterface +{ + /** + * @param ThrottleSettingsInterface $settings + * + * @return ThrottleSettingsInterface + */ + public function merge(ThrottleSettingsInterface $settings); + + /** + * @return bool + */ + public function isValid(); +} diff --git a/src/Throttle/Throttler/AbstractWindowThrottler.php b/src/Throttle/Throttler/AbstractWindowThrottler.php new file mode 100644 index 0000000..44ddd91 --- /dev/null +++ b/src/Throttle/Throttler/AbstractWindowThrottler.php @@ -0,0 +1,132 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Throttler; + +use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; +use Sunspikes\Ratelimit\Time\TimeAdapterInterface; + +abstract class AbstractWindowThrottler +{ + /** + * @var CacheAdapterInterface + */ + protected $cache; + + /** + * @var int|null + */ + protected $cacheTtl; + + /** + * @var int + */ + protected $hitLimit; + + /** + * @var string + */ + protected $key; + + /** + * @var int + */ + protected $timeLimit; + + /** + * @var TimeAdapterInterface + */ + protected $timeProvider; + + /** + * @param CacheAdapterInterface $cache + * @param TimeAdapterInterface $timeAdapter + * @param string $key Cache key prefix + * @param int $hitLimit Maximum number of hits + * @param int $timeLimit Length of window + * @param int|null $cacheTtl Cache ttl time (default: null => CacheAdapter ttl) + */ + public function __construct( + CacheAdapterInterface $cache, + TimeAdapterInterface $timeAdapter, + $key, + $hitLimit, + $timeLimit, + $cacheTtl = null + ) { + $this->cache = $cache; + $this->timeProvider = $timeAdapter; + $this->key = $key; + $this->hitLimit = $hitLimit; + $this->timeLimit = $timeLimit; + $this->cacheTtl = $cacheTtl; + } + + /** + * @inheritdoc + */ + public function access() + { + $status = $this->check(); + $this->hit(); + + return $status; + } + + + /** + * @inheritdoc + */ + public function check() + { + return $this->count() < $this->hitLimit; + } + + /** + * @inheritdoc + */ + public function getTime() + { + return $this->timeLimit; + } + + /** + * @inheritdoc + */ + public function getLimit() + { + return $this->hitLimit; + } + + /** + * @inheritdoc + */ + abstract public function hit(); + + /** + * @inheritdoc + */ + abstract public function count(); +} diff --git a/src/Throttle/Throttler/CacheThrottler.php b/src/Throttle/Throttler/ElasticWindowThrottler.php similarity index 82% rename from src/Throttle/Throttler/CacheThrottler.php rename to src/Throttle/Throttler/ElasticWindowThrottler.php index c8b9ab7..2973608 100644 --- a/src/Throttle/Throttler/CacheThrottler.php +++ b/src/Throttle/Throttler/ElasticWindowThrottler.php @@ -25,11 +25,12 @@ namespace Sunspikes\Ratelimit\Throttle\Throttler; +use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; use Sunspikes\Ratelimit\Cache\Exception\ItemNotFoundException; -class CacheThrottler implements ThrottlerInterface, \Countable +class ElasticWindowThrottler implements RetriableThrottlerInterface, \Countable { - /* @var \Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface */ + /* @var CacheAdapterInterface */ protected $cache; /* @var string */ protected $key; @@ -41,14 +42,12 @@ class CacheThrottler implements ThrottlerInterface, \Countable protected $counter; /** - * Short description for Function - * - * @param \Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface $cache + * @param CacheAdapterInterface $cache * @param string $key * @param int $limit * @param int $ttl */ - public function __construct($cache, $key, $limit, $ttl) + public function __construct(CacheAdapterInterface $cache, $key, $limit, $ttl) { $this->cache = $cache; $this->key = $key; @@ -119,32 +118,30 @@ public function check() } /** - * Get the cache adapter - * - * @return \Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface + * @inheritdoc */ - public function getCache() + public function getTime() { - return $this->cache; + return $this->ttl; } /** - * Get cache ttl - * - * @return int + * @inheritdoc */ - public function getTtl() + public function getLimit() { - return $this->ttl; + return $this->limit; } /** - * Get throttle limit - * - * @return int + * @inheritdoc */ - public function getLimit() + public function getRetryTimeout() { - return $this->limit; + if ($this->check()) { + return 0; + } + + return 1e3 * $this->ttl; } } diff --git a/src/Throttle/Throttler/FixedWindowThrottler.php b/src/Throttle/Throttler/FixedWindowThrottler.php new file mode 100644 index 0000000..f9b7b38 --- /dev/null +++ b/src/Throttle/Throttler/FixedWindowThrottler.php @@ -0,0 +1,120 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Throttler; + +use Sunspikes\Ratelimit\Cache\Exception\ItemNotFoundException; + +final class FixedWindowThrottler extends AbstractWindowThrottler implements RetriableThrottlerInterface +{ + const TIME_CACHE_KEY = ':time'; + const HITS_CACHE_KEY = ':hits'; + + /** + * @var int|null + */ + private $hitCount; + + /** + * @inheritdoc + */ + public function hit() + { + $this->setCachedHitCount($this->count() + 1); + + // Update the window start time if the previous window has passed, or no cached window exists + try { + if (($this->timeProvider->now() - $this->cache->get($this->key.self::TIME_CACHE_KEY)) > $this->timeLimit) { + $this->cache->set($this->key.self::TIME_CACHE_KEY, $this->timeProvider->now(), $this->cacheTtl); + } + } catch (ItemNotFoundException $exception) { + $this->cache->set($this->key.self::TIME_CACHE_KEY, $this->timeProvider->now(), $this->cacheTtl); + } + + return $this; + } + + /** + * @inheritdoc + */ + public function count() + { + try { + if (($this->timeProvider->now() - $this->cache->get($this->key.self::TIME_CACHE_KEY)) > $this->timeLimit) { + return 0; + } + + return $this->getCachedHitCount(); + } catch (ItemNotFoundException $exception) { + return 0; + } + } + + /** + * @inheritdoc + */ + public function clear() + { + $this->setCachedHitCount(0); + $this->cache->set($this->key.self::TIME_CACHE_KEY, $this->timeProvider->now(), $this->cacheTtl); + } + + /** + * @inheritdoc + */ + public function getRetryTimeout() + { + if ($this->check()) { + return 0; + } + + // Return the time until the current window ends + // Try/catch for the ItemNotFoundException is not required, in that case $this->check() will return true + return 1e3 * ($this->timeLimit - $this->timeProvider->now() + $this->cache->get($this->key.self::TIME_CACHE_KEY)); + } + + /** + * @return int + * + * @throws ItemNotFoundException + */ + private function getCachedHitCount() + { + if (null !== $this->hitCount) { + return $this->hitCount; + } + + return $this->cache->get($this->key.self::HITS_CACHE_KEY); + } + + /** + * @param int $hitCount + */ + private function setCachedHitCount($hitCount) + { + $this->hitCount = $hitCount; + $this->cache->set($this->key.self::HITS_CACHE_KEY, $hitCount, $this->cacheTtl); + } +} diff --git a/src/Throttle/Throttler/LeakyBucketThrottler.php b/src/Throttle/Throttler/LeakyBucketThrottler.php new file mode 100644 index 0000000..2714cc5 --- /dev/null +++ b/src/Throttle/Throttler/LeakyBucketThrottler.php @@ -0,0 +1,212 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Throttler; + +use Sunspikes\Ratelimit\Cache\Exception\ItemNotFoundException; +use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; +use Sunspikes\Ratelimit\Time\TimeAdapterInterface; + +final class LeakyBucketThrottler implements RetriableThrottlerInterface +{ + const TIME_CACHE_KEY = ':time'; + const TOKEN_CACHE_KEY = ':tokens'; + + /** + * @var CacheAdapterInterface + */ + private $cache; + + /** + * @var int|null + */ + private $cacheTtl; + + /** + * @var string + */ + private $key; + + /** + * @var int + */ + private $threshold; + + /** + * @var TimeAdapterInterface + */ + private $timeProvider; + + /** + * @var int + */ + private $timeLimit; + + /** + * @var int + */ + private $tokenlimit; + + /** + * @param CacheAdapterInterface $cache + * @param TimeAdapterInterface $timeAdapter + * @param string $key Cache key prefix + * @param int $tokenLimit Bucket capacity + * @param int $timeLimit Refill time in milliseconds + * @param int|null $threshold Capacity threshold on which to start throttling (default: 0) + * @param int|null $cacheTtl Cache ttl time (default: null => CacheAdapter ttl) + */ + public function __construct( + CacheAdapterInterface $cache, + TimeAdapterInterface $timeAdapter, + $key, + $tokenLimit, + $timeLimit, + $threshold = null, + $cacheTtl = null + ) { + $this->cache = $cache; + $this->timeProvider = $timeAdapter; + $this->key = $key; + $this->tokenlimit = $tokenLimit; + $this->timeLimit = $timeLimit; + $this->cacheTtl = $cacheTtl; + $this->threshold = null !== $threshold ? $threshold : 0; + } + + /** + * @inheritdoc + */ + public function access() + { + return 0 === $this->hit(); + } + + /** + * @inheritdoc + */ + public function hit() + { + $tokenCount = $this->count(); + + $this->setUsedCapacity($tokenCount + 1); + + if (0 < $wait = $this->getWaitTime($tokenCount)) { + $this->timeProvider->usleep(1e3 * $wait); + } + + return $wait; + } + + /** + * @inheritdoc + */ + public function clear() + { + $this->setUsedCapacity(0); + } + + /** + * @inheritdoc + */ + public function count() + { + try { + $timeSinceLastRequest = 1e3 * ($this->timeProvider->now() - $this->cache->get($this->key.self::TIME_CACHE_KEY)); + + if ($timeSinceLastRequest > $this->timeLimit) { + return 0; + } + + $lastTokenCount = $this->cache->get($this->key.self::TOKEN_CACHE_KEY); + } catch (ItemNotFoundException $exception) { + $this->clear(); //Clear the bucket + + return 0; + } + + // Return the `used` token count, minus the amount of tokens which have been `refilled` since the previous request + return (int) max(0, ceil($lastTokenCount - ($this->tokenlimit * $timeSinceLastRequest / ($this->timeLimit)))); + } + + /** + * @inheritdoc + */ + public function check() + { + return 0 === $this->getWaitTime($this->count()); + } + + /** + * @inheritdoc + */ + public function getTime() + { + return $this->timeLimit; + } + + /** + * @inheritdoc + */ + public function getLimit() + { + return $this->tokenlimit; + } + + /** + * @inheritdoc + */ + public function getRetryTimeout() + { + if ($this->threshold > $this->count() + 1) { + return 0; + } + + return (int) ceil($this->timeLimit / $this->tokenlimit); + } + + /** + * @param int $tokenCount + * + * @return int + */ + private function getWaitTime($tokenCount) + { + if ($this->threshold > $tokenCount) { + return 0; + } + + return (int) ceil($this->timeLimit / max(1, ($this->tokenlimit - $this->threshold))); + } + + /** + * @param int $tokens + */ + private function setUsedCapacity($tokens) + { + $this->cache->set($this->key.self::TOKEN_CACHE_KEY, $tokens, $this->cacheTtl); + $this->cache->set($this->key.self::TIME_CACHE_KEY, $this->timeProvider->now(), $this->cacheTtl); + } +} diff --git a/src/Throttle/Throttler/MovingWindowThrottler.php b/src/Throttle/Throttler/MovingWindowThrottler.php new file mode 100644 index 0000000..c1da97d --- /dev/null +++ b/src/Throttle/Throttler/MovingWindowThrottler.php @@ -0,0 +1,115 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Throttler; + +use Sunspikes\Ratelimit\Cache\Exception\ItemNotFoundException; + +final class MovingWindowThrottler extends AbstractWindowThrottler implements RetriableThrottlerInterface +{ + /** + * [Timestamp => recorded hits] + * + * @var array + */ + private $hitCountMapping = []; + + /** + * @inheritdoc + */ + public function hit() + { + $timestamp = (int) ceil($this->timeProvider->now()); + $this->updateHitCount(); + + if (!isset($this->hitCountMapping[$timestamp])) { + $this->hitCountMapping[$timestamp] = 0; + } + + //Adds 1 recorded hit to the mapping entry for the current timestamp + $this->hitCountMapping[$timestamp]++; + $this->cache->set($this->key, serialize($this->hitCountMapping), $this->cacheTtl); + + return $this; + } + + /** + * @inheritdoc + */ + public function count() + { + $this->updateHitCount(); + + return (int) array_sum($this->hitCountMapping); + } + + /** + * @inheritdoc + */ + public function getRetryTimeout() + { + if ($this->hitLimit > $totalHitCount = $this->count()) { + return 0; + } + + // Check at which 'oldest' possible timestamp enough hits have expired + // Then return the time remaining for that timestamp to expire + foreach ($this->hitCountMapping as $timestamp => $hitCount) { + if ($this->hitLimit > $totalHitCount -= $hitCount) { + return 1e3 * max(0, $this->timeLimit - ((int) ceil($this->timeProvider->now()) - $timestamp)); + } + } + + return 1e3 * $this->timeLimit; + } + + /** + * @inheritdoc + */ + public function clear() + { + $this->hitCountMapping = []; + $this->cache->set($this->key, serialize([]), $this->cacheTtl); + } + + private function updateHitCount() + { + try { + // Get a stored mapping from cache + if (0 === count($this->hitCountMapping)) { + $this->hitCountMapping = (array) unserialize($this->cache->get($this->key)); + } + } catch (ItemNotFoundException $exception) {} + + $startTime = (int) ceil($this->timeProvider->now()) - $this->timeLimit; + + // Clear all entries older than the window front-edge + $relevantTimestamps = array_filter(array_keys($this->hitCountMapping), function ($key) use ($startTime) { + return $startTime <= $key; + }); + + $this->hitCountMapping = array_intersect_key($this->hitCountMapping, array_flip($relevantTimestamps)); + } +} diff --git a/src/Throttle/Throttler/RetriableThrottlerInterface.php b/src/Throttle/Throttler/RetriableThrottlerInterface.php new file mode 100644 index 0000000..548d38a --- /dev/null +++ b/src/Throttle/Throttler/RetriableThrottlerInterface.php @@ -0,0 +1,36 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Throttler; + +interface RetriableThrottlerInterface extends ThrottlerInterface +{ + /** + * Return the number of milliseconds to wait before a valid request can be made + * + * @return int + */ + public function getRetryTimeout(); +} diff --git a/src/Throttle/Throttler/RetrialQueueThrottler.php b/src/Throttle/Throttler/RetrialQueueThrottler.php new file mode 100644 index 0000000..5b2da33 --- /dev/null +++ b/src/Throttle/Throttler/RetrialQueueThrottler.php @@ -0,0 +1,114 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Throttle\Throttler; + +use Sunspikes\Ratelimit\Time\TimeAdapterInterface; + +final class RetrialQueueThrottler implements ThrottlerInterface +{ + /** + * @var ThrottlerInterface + */ + private $internalThrottler; + + /** + * @var TimeAdapterInterface + */ + private $timeProvider; + + /** + * @param RetriableThrottlerInterface $internalThrottler + * @param TimeAdapterInterface $timeProvider + */ + public function __construct(RetriableThrottlerInterface $internalThrottler, TimeAdapterInterface $timeProvider) + { + $this->internalThrottler = $internalThrottler; + $this->timeProvider = $timeProvider; + } + + /** + * @inheritdoc + */ + public function access() + { + $status = $this->check(); + $this->hit(); + + return $status; + } + + /** + * @inheritdoc + */ + public function hit() + { + if (0 !== $waitTime = $this->internalThrottler->getRetryTimeout()) { + $this->timeProvider->usleep(1e3 * $waitTime); + } + + return $this->internalThrottler->hit(); + } + + /** + * @inheritdoc + */ + public function clear() + { + $this->internalThrottler->clear(); + } + + /** + * @inheritdoc + */ + public function count() + { + return $this->internalThrottler->count(); + } + + /** + * @inheritdoc + */ + public function check() + { + return $this->internalThrottler->check(); + } + + /** + * @inheritdoc + */ + public function getTime() + { + return $this->internalThrottler->getTime(); + } + + /** + * @inheritdoc + */ + public function getLimit() + { + return $this->internalThrottler->getLimit(); + } +} diff --git a/src/Throttle/Throttler/ThrottlerInterface.php b/src/Throttle/Throttler/ThrottlerInterface.php index 2bacf3f..ddd508b 100644 --- a/src/Throttle/Throttler/ThrottlerInterface.php +++ b/src/Throttle/Throttler/ThrottlerInterface.php @@ -61,4 +61,18 @@ public function count(); * @return bool */ public function check(); + + /** + * Get time window + * + * @return int + */ + public function getTime(); + + /** + * Get throttle limit + * + * @return int + */ + public function getLimit(); } diff --git a/src/Time/PhpTimeAdapter.php b/src/Time/PhpTimeAdapter.php new file mode 100644 index 0000000..5205eb8 --- /dev/null +++ b/src/Time/PhpTimeAdapter.php @@ -0,0 +1,47 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Time; + +final class PhpTimeAdapter implements TimeAdapterInterface +{ + /** + * @return float + */ + public function now() + { + list($usec, $sec) = explode(" ", microtime()); + + return ((float) $usec + (float) $sec); + } + + /** + * @param int $microseconds + */ + public function usleep($microseconds) + { + usleep($microseconds); + } +} diff --git a/src/Time/TimeAdapterInterface.php b/src/Time/TimeAdapterInterface.php new file mode 100644 index 0000000..f19a75e --- /dev/null +++ b/src/Time/TimeAdapterInterface.php @@ -0,0 +1,39 @@ + + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Sunspikes\Ratelimit\Time; + +interface TimeAdapterInterface +{ + /** + * @return float + */ + public function now(); + + /** + * @param int $microseconds + */ + public function usleep($microseconds); +} diff --git a/tests/Cache/Factory/DesarrollaCacheFactoryTest.php b/tests/Cache/Factory/DesarrollaCacheFactoryTest.php index 5fce56b..6386155 100644 --- a/tests/Cache/Factory/DesarrollaCacheFactoryTest.php +++ b/tests/Cache/Factory/DesarrollaCacheFactoryTest.php @@ -7,7 +7,7 @@ class DesarrollaCacheFactoryTest extends \PHPUnit_Framework_TestCase { - public function testFactory() + public function testMake() { $factory = new DesarrollaCacheFactory(); $cache = $factory->make(); diff --git a/tests/Functional/AbstractThrottlerTestCase.php b/tests/Functional/AbstractThrottlerTestCase.php new file mode 100644 index 0000000..a8cedb9 --- /dev/null +++ b/tests/Functional/AbstractThrottlerTestCase.php @@ -0,0 +1,97 @@ + 'memory', + 'memory' => ['limit' => 10], + ]); + + $this->ratelimiter = $this->createRatelimiter($cacheFactory); + } + + public function testThrottlePreLimit() + { + $throttle = $this->ratelimiter->get('pre-limit-test'); + + for ($i = 0; ++$i < $this->getMaxAttempts();) { + $throttle->hit(); + } + + $this->assertTrue($throttle->check()); + } + + public function testThrottlePostLimit() + { + $throttle = $this->ratelimiter->get('post-limit-test'); + + for ($i = 0; $i < $this->getMaxAttempts(); $i++) { + $throttle->hit(); + } + + $this->assertFalse($throttle->check()); + } + + public function testThrottleAccess() + { + $throttle = $this->ratelimiter->get('access-test'); + + for ($i = 0; $i < $this->getMaxAttempts(); $i++) { + $throttle->access(); + } + + $this->assertFalse($throttle->access()); + } + + public function testThrottleCount() + { + $throttle = $this->ratelimiter->get('count-test'); + + for ($i = 0; $i < $this->getMaxAttempts(); $i++) { + $throttle->access(); + } + + $this->assertEquals(3, $throttle->count()); + } + + public function testClear() + { + $throttle = $this->ratelimiter->get('clear-test'); + $throttle->hit(); + $throttle->clear(); + + self::assertEquals(0, $throttle->count()); + } + + /** + * @return int + */ + protected function getMaxAttempts() + { + return 3; + } + + /** + * @param FactoryInterface $cacheFactory + * + * @return RateLimiter + */ + abstract protected function createRatelimiter(FactoryInterface $cacheFactory); +} diff --git a/tests/Functional/ElasticWindowTest.php b/tests/Functional/ElasticWindowTest.php new file mode 100644 index 0000000..3912139 --- /dev/null +++ b/tests/Functional/ElasticWindowTest.php @@ -0,0 +1,25 @@ +make())), + new HydratorFactory(), + new ElasticWindowSettings($this->getMaxAttempts(), 600) + ); + } +} diff --git a/tests/Functional/FixedWindowTest.php b/tests/Functional/FixedWindowTest.php new file mode 100644 index 0000000..2f31940 --- /dev/null +++ b/tests/Functional/FixedWindowTest.php @@ -0,0 +1,64 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->timeAdapter->shouldReceive('now')->andReturn($this->startTime = time())->byDefault(); + + parent::setUp(); + } + + public function testWindowIsFixed() + { + $throttle = $this->ratelimiter->get('window-is-fixed'); + + for ($i = -1; $i < $this->getMaxAttempts(); $i++) { + $throttle->hit(); + } + + //override time + $this->timeAdapter->shouldReceive('now')->andReturn($this->startTime + self::TIME_LIMIT + 1); + + self::assertEquals(0, $throttle->count()); + } + + /** + * @inheritdoc + */ + protected function createRatelimiter(FactoryInterface $cacheFactory) + { + return new RateLimiter( + new TimeAwareThrottlerFactory(new DesarrollaCacheAdapter($cacheFactory->make()), $this->timeAdapter), + new HydratorFactory(), + new FixedWindowSettings($this->getMaxAttempts(), self::TIME_LIMIT) + ); + } +} diff --git a/tests/Functional/LeakyBucketTest.php b/tests/Functional/LeakyBucketTest.php new file mode 100644 index 0000000..706235c --- /dev/null +++ b/tests/Functional/LeakyBucketTest.php @@ -0,0 +1,54 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->timeAdapter->shouldReceive('now')->andReturn(time()); + + parent::setUp(); + } + + public function testThrottleAccess() + { + $expectedWaitTime = self::TIME_LIMIT / (self::TOKEN_LIMIT - $this->getMaxAttempts()); + $this->timeAdapter->shouldReceive('usleep')->with(1e3 * $expectedWaitTime)->once(); + + parent::testThrottleAccess(); + } + + /** + * @inheritdoc + */ + protected function createRatelimiter(FactoryInterface $cacheFactory) + { + return new RateLimiter( + new TimeAwareThrottlerFactory(new DesarrollaCacheAdapter($cacheFactory->make()), $this->timeAdapter), + new HydratorFactory(), + new LeakyBucketSettings(self::TOKEN_LIMIT, self::TIME_LIMIT, $this->getMaxAttempts()) + ); + } +} diff --git a/tests/Functional/MovingWindowTest.php b/tests/Functional/MovingWindowTest.php new file mode 100644 index 0000000..b5f6bbb --- /dev/null +++ b/tests/Functional/MovingWindowTest.php @@ -0,0 +1,74 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->timeAdapter->shouldReceive('now')->andReturn($this->startTime = time())->byDefault(); + + parent::setUp(); + } + + public function testWindowMoves() + { + $throttle = $this->ratelimiter->get('window-moves'); + + $timeValues = []; + + for ($i = 0; $i < $this->getMaxAttempts(); $i++) { + $timeValues[] = $this->startTime + $i; + $timeValues[] = $this->startTime + $i; + } + + $timeValues[] = $this->startTime + self::TIME_LIMIT + 1; + $timeValues[] = $this->startTime + self::TIME_LIMIT + 1; + + $this->timeAdapter->shouldReceive('now')->andReturnValues($timeValues); + + for ($i = 0; $i < $this->getMaxAttempts() + 1; $i++) { + $throttle->hit(); + } + + // First hit should have expired + self::assertEquals($this->getMaxAttempts(), $throttle->count()); + } + + /** + * @inheritdoc + */ + protected function createRatelimiter(FactoryInterface $cacheFactory) + { + return new RateLimiter( + new TimeAwareThrottlerFactory(new DesarrollaCacheAdapter($cacheFactory->make()), $this->timeAdapter), + new HydratorFactory(), + new MovingWindowSettings($this->getMaxAttempts(), self::TIME_LIMIT) + ); + } +} diff --git a/tests/Functional/RetrialQueueTest.php b/tests/Functional/RetrialQueueTest.php new file mode 100644 index 0000000..506d8f4 --- /dev/null +++ b/tests/Functional/RetrialQueueTest.php @@ -0,0 +1,53 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->timeAdapter->shouldReceive('now')->andReturn(time()); + + parent::setUp(); + } + + public function testThrottleAccess() + { + $this->timeAdapter->shouldReceive('usleep')->with(1e6 * self::TIME_LIMIT)->once(); + + parent::testThrottleAccess(); + } + + /** + * @inheritdoc + */ + protected function createRatelimiter(FactoryInterface $cacheFactory) + { + return new RateLimiter( + new TimeAwareThrottlerFactory(new DesarrollaCacheAdapter($cacheFactory->make()), $this->timeAdapter), + new HydratorFactory(), + new RetrialQueueSettings(new FixedWindowSettings($this->getMaxAttempts(), self::TIME_LIMIT)) + ); + } +} diff --git a/tests/RatelimiterTest.php b/tests/RatelimiterTest.php index 9e10467..4d28d9c 100644 --- a/tests/RatelimiterTest.php +++ b/tests/RatelimiterTest.php @@ -3,85 +3,135 @@ namespace Sunspikes\Tests\Ratelimit; use Mockery as M; -use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; -use Sunspikes\Ratelimit\Cache\Exception\ItemNotFoundException; use Sunspikes\Ratelimit\RateLimiter; -use Sunspikes\Ratelimit\Throttle\Factory\ThrottlerFactory; -use Sunspikes\Ratelimit\Throttle\Hydrator\HydratorFactory; +use Sunspikes\Ratelimit\Throttle\Entity\Data; +use Sunspikes\Ratelimit\Throttle\Factory\FactoryInterface as ThrottlerFactoryInterface; +use Sunspikes\Ratelimit\Throttle\Hydrator\DataHydratorInterface; +use Sunspikes\Ratelimit\Throttle\Hydrator\FactoryInterface as HydratorFactoryInterface; +use Sunspikes\Ratelimit\Throttle\Settings\ElasticWindowSettings; +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; +use Sunspikes\Ratelimit\Throttle\Throttler\ThrottlerInterface; class RatelimiterTest extends \PHPUnit_Framework_TestCase { /** - * @var CacheAdapterInterface|M\MockInterface + * @var ThrottleSettingsInterface|M\MockInterface */ - private $mockCacheAdapter; + private $defaultSettings; /** - * @var RateLimiter + * @var ThrottlerFactoryInterface|M\MockInterface + */ + private $throttlerFactory; + + /** + * @var HydratorFactoryInterface|M\MockInterface + */ + private $hydratorFactory; + + /** + * @var Ratelimiter */ private $ratelimiter; - public function setUp() + /** + * @inheritdoc + */ + protected function setUp() { - $throttlerFactory = new ThrottlerFactory(); - $hydratorFactory = new HydratorFactory(); - $this->mockCacheAdapter = M::mock(CacheAdapterInterface::class); + $this->throttlerFactory = M::mock(ThrottlerFactoryInterface::class); + $this->hydratorFactory = M::mock(HydratorFactoryInterface::class); + $this->defaultSettings = M::mock(ThrottleSettingsInterface::class); + + $this->ratelimiter = new RateLimiter( + $this->throttlerFactory, + $this->hydratorFactory, + $this->defaultSettings + ); + } - $this->ratelimiter = new RateLimiter($throttlerFactory, $hydratorFactory, $this->mockCacheAdapter, 3, 600); + public function testGetWithInvalidData() + { + $this->setExpectedException(\InvalidArgumentException::class); + $this->ratelimiter->get(''); } - public function testThrottlePreLimit() + public function testGetWithDefaultSettings() { - $this->mockCacheAdapter->shouldReceive('set')->times(2); - $this->mockCacheAdapter->shouldReceive('get')->once()->andThrow(ItemNotFoundException::class); - $this->mockCacheAdapter->shouldReceive('get')->once()->andReturn(2); + $object = $this->getHydratedObject('key'); - $throttle = $this->ratelimiter->get('pre-limit-test'); - $throttle->hit(); - $throttle->hit(); + $this->throttlerFactory + ->shouldReceive('make') + ->with($object, $this->defaultSettings) + ->andReturn(M::mock(ThrottlerInterface::class)); - $this->assertTrue($throttle->check()); + self::assertInstanceOf(ThrottlerInterface::class, $this->ratelimiter->get('key')); } - public function testThrottlePostLimit() + public function testGetWithMergableSettings() { - $this->mockCacheAdapter->shouldReceive('set')->times(3); - $this->mockCacheAdapter->shouldReceive('get')->once()->andThrow(ItemNotFoundException::class); - $this->mockCacheAdapter->shouldReceive('get')->twice()->andReturn(2, 3); + $object = $this->getHydratedObject('key'); + + $this->defaultSettings->shouldReceive('merge')->once()->andReturn(M::mock(ThrottleSettingsInterface::class)); - $throttle = $this->ratelimiter->get('post-limit-test'); - $throttle->hit(); - $throttle->hit(); - $throttle->hit(); + $this->throttlerFactory + ->shouldReceive('make') + ->with($object, M::type(ThrottleSettingsInterface::class)) + ->andReturn(M::mock(ThrottlerInterface::class)); - $this->assertFalse($throttle->check()); + self::assertInstanceOf(ThrottlerInterface::class, $this->ratelimiter->get('key', new ElasticWindowSettings())); } - public function testThrottleAccess() + public function testGetWithUnmergableSettings() { - $this->mockCacheAdapter->shouldReceive('set')->times(4); - $this->mockCacheAdapter->shouldReceive('get')->once()->andThrow(ItemNotFoundException::class); - $this->mockCacheAdapter->shouldReceive('get')->times(3)->andReturn(2, 3, 4); + $object = $this->getHydratedObject('key'); + + $newSettings = M::mock(ThrottleSettingsInterface::class); + $this->defaultSettings->shouldReceive('merge')->once()->andReturn($newSettings); - $throttle = $this->ratelimiter->get('access-test'); - $throttle->access(); - $throttle->access(); - $throttle->access(); + $this->throttlerFactory + ->shouldReceive('make') + ->with($object, $newSettings) + ->andReturn(M::mock(ThrottlerInterface::class)); - $this->assertFalse($throttle->access()); + self::assertInstanceOf(ThrottlerInterface::class, $this->ratelimiter->get('key', $newSettings)); } - public function testThrottleCount() + public function testGetThrottlerCaching() { - $this->mockCacheAdapter->shouldReceive('set')->times(3); - $this->mockCacheAdapter->shouldReceive('get')->once()->andThrow(ItemNotFoundException::class); - $this->mockCacheAdapter->shouldReceive('get')->times(3)->andReturn(2, 3, 3); + $object1 = $this->getHydratedObject('key1'); + $object2 = $this->getHydratedObject('key2'); + + $this->throttlerFactory + ->shouldReceive('make') + ->with($object1, M::type(ThrottleSettingsInterface::class)) + ->once() + ->andReturn(M::mock(ThrottlerInterface::class)); + + $this->throttlerFactory + ->shouldReceive('make') + ->with($object2, M::type(ThrottleSettingsInterface::class)) + ->once() + ->andReturn(M::mock(ThrottlerInterface::class)); + + self::assertSame($this->ratelimiter->get('key1'), $this->ratelimiter->get('key1')); + self::assertNotSame($this->ratelimiter->get('key1'), $this->ratelimiter->get('key2')); + } + + /** + * @param string $key + * + * @return Data + */ + private function getHydratedObject($key) + { + $object = new Data('data-'.$key); + + $dataHydrator = M::mock(DataHydratorInterface::class); + $dataHydrator->shouldReceive('hydrate')->with($key)->andReturn($object); - $throttle = $this->ratelimiter->get('count-test'); - $throttle->access(); - $throttle->access(); - $throttle->access(); + $this->hydratorFactory->shouldReceive('make')->with($key)->andReturn($dataHydrator); - $this->assertEquals(3, $throttle->count()); + return $object; } } diff --git a/tests/Throttle/Entity/DataTest.php b/tests/Throttle/Entity/DataTest.php index 4932196..591e0f9 100644 --- a/tests/Throttle/Entity/DataTest.php +++ b/tests/Throttle/Entity/DataTest.php @@ -6,11 +6,14 @@ class DataTest extends \PHPUnit_Framework_TestCase { + /** + * @var Data + */ private $data; public function setUp() { - $this->data = new Data('test', 3, 60); + $this->data = new Data('test'); } public function testGetData() @@ -18,16 +21,6 @@ public function testGetData() $this->assertEquals('test', $this->data->getData()); } - public function testGetLimit() - { - $this->assertEquals(3, $this->data->getLimit()); - } - - public function testGetTtl() - { - $this->assertEquals(60, $this->data->getTtl()); - } - public function testGetKey() { $this->assertEquals(sha1('test'), $this->data->getKey()); diff --git a/tests/Throttle/Factory/ThrottlerFactoryTest.php b/tests/Throttle/Factory/ThrottlerFactoryTest.php index 5501370..ff7b432 100644 --- a/tests/Throttle/Factory/ThrottlerFactoryTest.php +++ b/tests/Throttle/Factory/ThrottlerFactoryTest.php @@ -3,28 +3,64 @@ namespace Sunspikes\Tests\Ratelimit\Throttle\Factory; use Mockery as M; +use Mockery\MockInterface; +use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; +use Sunspikes\Ratelimit\Throttle\Entity\Data; use Sunspikes\Ratelimit\Throttle\Factory\ThrottlerFactory; +use Sunspikes\Ratelimit\Throttle\Settings\ElasticWindowSettings; +use Sunspikes\Ratelimit\Throttle\Settings\ThrottleSettingsInterface; +use Sunspikes\Ratelimit\Throttle\Throttler\ElasticWindowThrottler; +use Sunspikes\Ratelimit\Throttle\Factory\FactoryInterface; class ThrottlerFactoryTest extends \PHPUnit_Framework_TestCase { - public function testFactory() + /** + * @var CacheAdapterInterface|MockInterface + */ + protected $cacheAdapter; + + /** + * @var FactoryInterface + */ + protected $factory; + + /** + * @inheritdoc + */ + protected function setUp() + { + $this->cacheAdapter = M::mock(CacheAdapterInterface::class); + $this->factory = new ThrottlerFactory($this->cacheAdapter); + } + + public function testMakeElasticWindow() + { + self::assertInstanceOf( + ElasticWindowThrottler::class, + $this->factory->make($this->getData(), new ElasticWindowSettings(3, 600)) + ); + } + + public function testInvalidSettings() + { + $this->setExpectedException(\InvalidArgumentException::class); + $this->factory->make($this->getData(), new ElasticWindowSettings()); + } + + public function testUnknownSettings() + { + $settings = M::mock(ThrottleSettingsInterface::class); + $settings->shouldReceive('isValid')->andReturn(true); + + $this->setExpectedException(\InvalidArgumentException::class); + $this->factory->make($this->getData(), $settings); + } + + /** + * @return Data + */ + protected function getData() { - $dataMock = M::mock('\Sunspikes\Ratelimit\Throttle\Entity\Data'); - $dataMock->shouldReceive('getKey') - ->andReturn('getKey') - ->once(); - $dataMock->shouldReceive('getLimit') - ->andReturn(3) - ->once(); - $dataMock->shouldReceive('getTtl') - ->andReturn(600) - ->once(); - - $adapterMock = M::mock('\Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface'); - - $factory = new ThrottlerFactory(); - $throttler = $factory->make($dataMock, $adapterMock); - - $this->assertInstanceOf('\Sunspikes\Ratelimit\Throttle\Throttler\CacheThrottler', $throttler); + return new Data('someKey'); } -} \ No newline at end of file +} diff --git a/tests/Throttle/Factory/TimeAwareThrottlerFactoryTest.php b/tests/Throttle/Factory/TimeAwareThrottlerFactoryTest.php new file mode 100644 index 0000000..0561299 --- /dev/null +++ b/tests/Throttle/Factory/TimeAwareThrottlerFactoryTest.php @@ -0,0 +1,76 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->cacheAdapter = M::mock(CacheAdapterInterface::class); + + $this->factory = new TimeAwareThrottlerFactory($this->cacheAdapter, $this->timeAdapter); + } + + public function testMakeLeakyBucket() + { + self::assertInstanceOf( + LeakyBucketThrottler::class, + $this->factory->make($this->getData(), new LeakyBucketSettings(120, 60)) + ); + } + + public function testMakeMovingWindow() + { + self::assertInstanceOf( + MovingWindowThrottler::class, + $this->factory->make($this->getData(), new MovingWindowSettings(120, 60)) + ); + } + + public function testMakeFixedWindow() + { + self::assertInstanceOf( + FixedWindowThrottler::class, + $this->factory->make($this->getData(), new FixedWindowSettings(120, 60)) + ); + } + + public function testMakeRetrialQueue() + { + self::assertInstanceOf( + RetrialQueueThrottler::class, + $this->factory->make( + $this->getData(), + new RetrialQueueSettings(new MovingWindowSettings(120, 60)) + ) + ); + } +} diff --git a/tests/Throttle/Hydrator/ArrayHydratorTest.php b/tests/Throttle/Hydrator/ArrayHydratorTest.php index 057ff85..e852d7b 100644 --- a/tests/Throttle/Hydrator/ArrayHydratorTest.php +++ b/tests/Throttle/Hydrator/ArrayHydratorTest.php @@ -2,6 +2,7 @@ namespace Sunspikes\Tests\Ratelimit\Throttle\Hydrator; +use Sunspikes\Ratelimit\Throttle\Entity\Data; use Sunspikes\Ratelimit\Throttle\Hydrator\ArrayHydrator; class ArrayHydratorTest extends \PHPUnit_Framework_TestCase @@ -9,8 +10,8 @@ class ArrayHydratorTest extends \PHPUnit_Framework_TestCase public function testHydrate() { $arrayHydrator = new ArrayHydrator(); - $data = $arrayHydrator->hydrate([], 3, 600); + $data = $arrayHydrator->hydrate([]); - $this->assertInstanceOf('\Sunspikes\Ratelimit\Throttle\Entity\Data', $data); + $this->assertInstanceOf(Data::class, $data); } -} \ No newline at end of file +} diff --git a/tests/Throttle/Hydrator/StringHydratorTest.php b/tests/Throttle/Hydrator/StringHydratorTest.php index a6c8380..9afb842 100644 --- a/tests/Throttle/Hydrator/StringHydratorTest.php +++ b/tests/Throttle/Hydrator/StringHydratorTest.php @@ -2,6 +2,7 @@ namespace Sunspikes\Tests\Ratelimit\Throttle\Hydrator; +use Sunspikes\Ratelimit\Throttle\Entity\Data; use Sunspikes\Ratelimit\Throttle\Hydrator\StringHydrator; class StringHydratorTest extends \PHPUnit_Framework_TestCase @@ -9,8 +10,8 @@ class StringHydratorTest extends \PHPUnit_Framework_TestCase public function testHydrate() { $stringHydrator = new StringHydrator(); - $data = $stringHydrator->hydrate('test', 3, 600); + $data = $stringHydrator->hydrate('test'); - $this->assertInstanceOf('\Sunspikes\Ratelimit\Throttle\Entity\Data', $data); + $this->assertInstanceOf(Data::class, $data); } -} \ No newline at end of file +} diff --git a/tests/Throttle/Settings/AbstractWindowSettingsTest.php b/tests/Throttle/Settings/AbstractWindowSettingsTest.php new file mode 100644 index 0000000..1e22e50 --- /dev/null +++ b/tests/Throttle/Settings/AbstractWindowSettingsTest.php @@ -0,0 +1,65 @@ +getSettings(120, 60, 3600)->merge($this->getSettings()); + + self::assertEquals(120, $mergedSettings->getHitLimit()); + self::assertEquals(60, $mergedSettings->getTimeLimit()); + self::assertEquals(3600, $mergedSettings->getCacheTtl()); + } + + public function testMergeWithNonEmpty() + { + $mergedSettings = $this->getSettings(null, 60, null)->merge($this->getSettings(120, null, null)); + + self::assertEquals(120, $mergedSettings->getHitLimit()); + self::assertEquals(60, $mergedSettings->getTimeLimit()); + self::assertEquals(null, $mergedSettings->getCacheTtl()); + } + + public function testInvalidMerge() + { + $this->setExpectedException(\InvalidArgumentException::class); + $this->getSettings()->merge(M::mock(ThrottleSettingsInterface::class)); + } + + /** + * @dataProvider inputProvider + */ + public function testIsValid($tokenLimit, $timeLimit, $result) + { + self::assertEquals($result, $this->getSettings($tokenLimit, $timeLimit)->isValid()); + } + + /** + * @return array + */ + public function inputProvider() + { + return [ + [null, null, false], + [null, 600, false], + [3, null, false], + [3, 0, false], + [30, 600, true], + ]; + } + + /** + * @param int|null $hitLimit + * @param int|null $timeLimit + * @param int|null $cacheTtl + * + * @return AbstractWindowSettings + */ + abstract protected function getSettings($hitLimit = null, $timeLimit = null, $cacheTtl = null); +} diff --git a/tests/Throttle/Settings/ElasticWindowSettingsTest.php b/tests/Throttle/Settings/ElasticWindowSettingsTest.php new file mode 100644 index 0000000..87c7d0d --- /dev/null +++ b/tests/Throttle/Settings/ElasticWindowSettingsTest.php @@ -0,0 +1,55 @@ +merge(new ElasticWindowSettings()); + + self::assertEquals(3, $mergedSettings->getLimit()); + self::assertEquals(600, $mergedSettings->getTime()); + } + + public function testMergeWithNonEmpty() + { + $settings = new ElasticWindowSettings(null, 600); + $mergedSettings = $settings->merge(new ElasticWindowSettings(3, 700)); + + self::assertEquals(3, $mergedSettings->getLimit()); + self::assertEquals(700, $mergedSettings->getTime()); + } + + public function testInvalidMerge() + { + $this->setExpectedException(\InvalidArgumentException::class); + (new ElasticWindowSettings())->merge(M::mock(ThrottleSettingsInterface::class)); + } + + /** + * @dataProvider inputProvider + */ + public function testIsValid($limit, $time, $result) + { + self::assertEquals($result, (new ElasticWindowSettings($limit, $time))->isValid()); + } + + /** + * @return array + */ + public function inputProvider() + { + return [ + [null, null, false], + [null, 600, false], + [3, null, false], + [3, 600, true], + ]; + } +} diff --git a/tests/Throttle/Settings/FixedWindowSettingsTest.php b/tests/Throttle/Settings/FixedWindowSettingsTest.php new file mode 100644 index 0000000..7ea443d --- /dev/null +++ b/tests/Throttle/Settings/FixedWindowSettingsTest.php @@ -0,0 +1,17 @@ +merge(new LeakyBucketSettings()); + + self::assertEquals(120, $mergedSettings->getTokenLimit()); + self::assertEquals(60, $mergedSettings->getTimeLimit()); + self::assertEquals(30, $mergedSettings->getThreshold()); + self::assertEquals(3600, $mergedSettings->getCacheTtl()); + } + + public function testMergeWithNonEmpty() + { + $settings = new LeakyBucketSettings(null, 60, 30, null); + $mergedSettings = $settings->merge(new LeakyBucketSettings(120, null, 40, null)); + + self::assertEquals(120, $mergedSettings->getTokenLimit()); + self::assertEquals(60, $mergedSettings->getTimeLimit()); + self::assertEquals(40, $mergedSettings->getThreshold()); + self::assertEquals(null, $mergedSettings->getCacheTtl()); + } + + public function testInvalidMerge() + { + $this->setExpectedException(\InvalidArgumentException::class); + (new LeakyBucketSettings())->merge(M::mock(ThrottleSettingsInterface::class)); + } + + /** + * @dataProvider inputProvider + */ + public function testIsValid($tokenLimit, $timeLimit, $threshold, $result) + { + self::assertEquals($result, (new LeakyBucketSettings($tokenLimit, $timeLimit, $threshold))->isValid()); + } + + /** + * @return array + */ + public function inputProvider() + { + return [ + [null, null, null, false], + [null, 600, null, false], + [3, null, null, false], + [3, 0, null, false], + [3, 600, 3, true], + [30, 600, 15, true], + ]; + } +} diff --git a/tests/Throttle/Settings/MovingWindowSettingsTest.php b/tests/Throttle/Settings/MovingWindowSettingsTest.php new file mode 100644 index 0000000..e2e16bd --- /dev/null +++ b/tests/Throttle/Settings/MovingWindowSettingsTest.php @@ -0,0 +1,17 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->cacheAdapter = M::mock(CacheAdapterInterface::class); + + $this->throttler = $this->createThrottler('key'); + } + + public function testAccess() + { + $this->mockTimePassed(self::TIME_LIMIT + 2); + + $this->assertEquals(true, $this->throttler->access()); + } + + public function testCountWithMissingCacheItem() + { + $this->timeAdapter->shouldReceive('now')->once()->andReturn(self::INITIAL_TIME + 1); + $this->cacheAdapter->shouldReceive('get')->andThrow(ItemNotFoundException::class); + + self::assertEquals(0, $this->throttler->count()); + } + + public function testCountWithMoreTimePassedThanLimit() + { + //More time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT + 1); + + $this->assertEquals(0, $this->throttler->count()); + } + + public function testCheck() + { + //More time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT + 1); + + $this->assertTrue($this->throttler->check()); + } + + abstract public function testCountWithLessTimePassedThanLimit(); + + /** + * @param string $key + * + * @return AbstractWindowThrottler + */ + abstract protected function createThrottler($key); + + /** + * @param int $timeDiff + */ + protected function mockTimePassed($timeDiff) + { + $this->timeAdapter->shouldReceive('now')->andReturn(self::INITIAL_TIME + $timeDiff); + } +} diff --git a/tests/Throttle/Throttler/CacheThrottlerTest.php b/tests/Throttle/Throttler/ElasticWindowThrottlerTest.php similarity index 62% rename from tests/Throttle/Throttler/CacheThrottlerTest.php rename to tests/Throttle/Throttler/ElasticWindowThrottlerTest.php index 27e56cc..f7a266d 100644 --- a/tests/Throttle/Throttler/CacheThrottlerTest.php +++ b/tests/Throttle/Throttler/ElasticWindowThrottlerTest.php @@ -4,13 +4,21 @@ use Mockery as M; use Sunspikes\Ratelimit\Cache\Adapter\CacheAdapterInterface; -use Sunspikes\Ratelimit\Throttle\Throttler\CacheThrottler; +use Sunspikes\Ratelimit\Throttle\Throttler\ElasticWindowThrottler; -class CacheThrottlerTest extends \PHPUnit_Framework_TestCase +class ElasticWindowThrottlerTest extends \PHPUnit_Framework_TestCase { + const TTL = 600; + + /** + * @var ElasticWindowThrottler + */ private $throttler; - public function setUp() + /** + * @inheritdoc + */ + protected function setUp() { $cacheAdapter = M::mock(CacheAdapterInterface::class); @@ -22,7 +30,7 @@ public function setUp() ->with('key') ->andReturn(0, 1, 2, 3, 4); - $this->throttler = new CacheThrottler($cacheAdapter, 'key', 3, 600); + $this->throttler = new ElasticWindowThrottler($cacheAdapter, 'key', 3, self::TTL); } public function testAccess() @@ -48,19 +56,25 @@ public function testCount() public function testCheck() { - $this->assertTrue(true, $this->throttler->check()); + $this->assertTrue($this->throttler->check()); } - public function testGetCache() + public function testThrottle() { - $this->assertEquals(1, count($this->throttler->getCache())); + $this->throttler->hit(); + $this->throttler->hit(); + $this->throttler->hit(); + $this->assertFalse($this->throttler->access()); } - public function testThrottle() + public function testGetRetryTimeout() { + $this->assertEquals(0, $this->throttler->getRetryTimeout()); + $this->throttler->hit(); $this->throttler->hit(); $this->throttler->hit(); - $this->assertFalse($this->throttler->access()); + + $this->assertEquals(1e3 * self::TTL, $this->throttler->getRetryTimeout()); } } diff --git a/tests/Throttle/Throttler/FixedWindowThrottlerTest.php b/tests/Throttle/Throttler/FixedWindowThrottlerTest.php new file mode 100644 index 0000000..c3a121f --- /dev/null +++ b/tests/Throttle/Throttler/FixedWindowThrottlerTest.php @@ -0,0 +1,102 @@ +cacheAdapter + ->shouldReceive('set') + ->with('key'.FixedWindowThrottler::HITS_CACHE_KEY, 1, self::CACHE_TTL) + ->once(); + + $this->cacheAdapter + ->shouldReceive('set') + ->with('key'.FixedWindowThrottler::TIME_CACHE_KEY, self::TIME_LIMIT + 2, self::CACHE_TTL) + ->once(); + + parent::testAccess(); + } + + public function testClear() + { + $this->timeAdapter->shouldReceive('now')->once()->andReturn(self::INITIAL_TIME + 3); + + $this->cacheAdapter + ->shouldReceive('set') + ->with('key'.FixedWindowThrottler::TIME_CACHE_KEY, self::INITIAL_TIME + 3, self::CACHE_TTL) + ->once(); + + $this->cacheAdapter + ->shouldReceive('set') + ->with('key'.FixedWindowThrottler::HITS_CACHE_KEY, 0, self::CACHE_TTL) + ->once(); + + $this->throttler->clear(); + } + + public function testCountWithLessTimePassedThanLimit() + { + //Less time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT / 6); + + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.FixedWindowThrottler::HITS_CACHE_KEY) + ->andReturn(self::HIT_LIMIT / 3); + + $this->assertEquals(self::HIT_LIMIT / 3, $this->throttler->count()); + } + + public function testGetRetryTimeoutPreLimit() + { + //More time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT + 1); + + $this->assertEquals(0, $this->throttler->getRetryTimeout()); + } + + public function testGetRetryTimeoutPostLimit() + { + //Less time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT / 2); + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.FixedWindowThrottler::HITS_CACHE_KEY) + ->andReturn(self::HIT_LIMIT + 1); + + $this->assertEquals(5e2 * self::TIME_LIMIT, $this->throttler->getRetryTimeout()); + } + + /** + * @inheritdoc + */ + protected function createThrottler($key) + { + return new FixedWindowThrottler( + $this->cacheAdapter, + $this->timeAdapter, + $key, + self::HIT_LIMIT, + self::TIME_LIMIT, + self::CACHE_TTL + ); + } + + /** + * @param int $timeDiff + */ + protected function mockTimePassed($timeDiff) + { + parent::mockTimePassed($timeDiff); + + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.FixedWindowThrottler::TIME_CACHE_KEY) + ->andReturn(self::INITIAL_TIME); + } +} diff --git a/tests/Throttle/Throttler/LeakyBucketThrottlerTest.php b/tests/Throttle/Throttler/LeakyBucketThrottlerTest.php new file mode 100644 index 0000000..585ea2e --- /dev/null +++ b/tests/Throttle/Throttler/LeakyBucketThrottlerTest.php @@ -0,0 +1,199 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->cacheAdapter = M::mock(CacheAdapterInterface::class); + + $this->throttler = new LeakyBucketThrottler( + $this->cacheAdapter, + $this->timeAdapter, + 'key', + self::TOKEN_LIMIT, + self::TIME_LIMIT, + self::THRESHOLD, + self::CACHE_TTL + ); + } + + public function testAccess() + { + //More time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT + 1, 2); + $this->mockSetUsedCapacity(1, (self::INITIAL_TIME + self::TIME_LIMIT + 1) / 1e3); + + $this->assertEquals(true, $this->throttler->access()); + } + + public function testHitBelowThreshold() + { + // No time has passed + $this->mockTimePassed(0, 2); + + // Used tokens one below threshold + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.LeakyBucketThrottler::TOKEN_CACHE_KEY) + ->andReturn(self::THRESHOLD - 1); + + $this->mockSetUsedCapacity(self::THRESHOLD, self::INITIAL_TIME); + + $this->assertEquals(0, $this->throttler->hit()); + } + + public function testHitOnThreshold() + { + // No time has passed + $this->mockTimePassed(0, 2); + + // Used tokens on threshold + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.LeakyBucketThrottler::TOKEN_CACHE_KEY) + ->andReturn(self::THRESHOLD); + + $this->mockSetUsedCapacity(self::THRESHOLD + 1, self::INITIAL_TIME); + + $expectedWaitTime = self::TIME_LIMIT / (self::TOKEN_LIMIT - self::THRESHOLD); + $this->timeAdapter->shouldReceive('usleep')->with(1e3 * $expectedWaitTime)->once()->ordered(); + + $this->assertEquals($expectedWaitTime, $this->throttler->hit()); + } + + public function testClear() + { + $this->timeAdapter->shouldReceive('now')->once()->andReturn(self::INITIAL_TIME + 1); + $this->mockSetUsedCapacity(0, self::INITIAL_TIME + 1); + + $this->throttler->clear(); + } + + public function testCountWithMissingCacheItem() + { + $this->timeAdapter->shouldReceive('now')->twice()->andReturn(self::INITIAL_TIME + 1); + $this->cacheAdapter->shouldReceive('get')->andThrow(ItemNotFoundException::class); + + $this->mockSetUsedCapacity(0, self::INITIAL_TIME + 1); + + self::assertEquals(0, $this->throttler->count()); + } + + public function testCountWithMoreTimePassedThanLimit() + { + //More time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT + 1, 1); + + $this->assertEquals(0, $this->throttler->count()); + } + + public function testCountWithLessTimePassedThanLimit() + { + // Time passed to refill 1/6 of tokens + $this->mockTimePassed(self::TIME_LIMIT / 6, 1); + + // Previously 1/2 of tokens used + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.LeakyBucketThrottler::TOKEN_CACHE_KEY) + ->andReturn(self::TOKEN_LIMIT / 2); + + // So bucket should be filled for 1/3 + $this->assertEquals(self::TOKEN_LIMIT / 3, $this->throttler->count()); + } + + public function testCheck() + { + //More time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT + 1, 1); + + $this->assertTrue($this->throttler->check()); + } + + + public function testGetRetryTimeoutPreLimit() + { + $this->mockTimePassed(self::TIME_LIMIT + 2, 1); + + $this->assertEquals(0, $this->throttler->getRetryTimeout()); + } + + public function testGetRetryTimeoutPostLimit() + { + $this->mockTimePassed(0, 1); + + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.LeakyBucketThrottler::TOKEN_CACHE_KEY) + ->andReturn(self::THRESHOLD); + + $this->assertSame((int) ceil(self::TIME_LIMIT / self::TOKEN_LIMIT), $this->throttler->getRetryTimeout()); + } + + /** + * @param int $tokens + * @param int $time + */ + private function mockSetUsedCapacity($tokens, $time) + { + $this->cacheAdapter + ->shouldReceive('set') + ->with('key'.LeakyBucketThrottler::TOKEN_CACHE_KEY, $tokens, self::CACHE_TTL) + ->once() + ->ordered('set-cache'); + + $this->cacheAdapter + ->shouldReceive('set') + ->with('key'.LeakyBucketThrottler::TIME_CACHE_KEY, $time, self::CACHE_TTL) + ->once() + ->ordered('set-cache'); + } + + /** + * @param int $timeDiff + * @param int $numCalls + */ + private function mockTimePassed($timeDiff, $numCalls) + { + $this->timeAdapter->shouldReceive('now')->times($numCalls)->andReturn((self::INITIAL_TIME + $timeDiff) / 1e3); + + $this->cacheAdapter + ->shouldReceive('get') + ->with('key'.LeakyBucketThrottler::TIME_CACHE_KEY) + ->andReturn(self::INITIAL_TIME / 1e3); + } +} diff --git a/tests/Throttle/Throttler/MovingWindowThrottlerTest.php b/tests/Throttle/Throttler/MovingWindowThrottlerTest.php new file mode 100644 index 0000000..f95760f --- /dev/null +++ b/tests/Throttle/Throttler/MovingWindowThrottlerTest.php @@ -0,0 +1,97 @@ +cacheAdapter->shouldReceive('get')->with('key')->andReturn(serialize([]))->byDefault(); + } + + public function testAccess() + { + $this->cacheAdapter->shouldReceive('get') + ->with('key') + ->andReturn(serialize([self::INITIAL_TIME - self::TIME_LIMIT - 1 => self::HIT_LIMIT + 1])); + + $this->cacheAdapter->shouldReceive('set') + ->with('key', serialize([self::INITIAL_TIME + self::TIME_LIMIT + 2 => 1]), self::CACHE_TTL) + ->once(); + + parent::testAccess(); + } + + public function testClear() + { + $this->cacheAdapter->shouldReceive('set') + ->with('key', serialize([]), self::CACHE_TTL) + ->once(); + + $this->throttler->clear(); + } + + public function testCountWithLessTimePassedThanLimit() + { + //Less time has passed than the given window + $this->mockTimePassed(self::TIME_LIMIT / 6); + + $this->cacheAdapter->shouldReceive('get') + ->with('key') + ->andReturn(serialize([ + self::INITIAL_TIME - self::TIME_LIMIT => self::HIT_LIMIT / 2, + self::INITIAL_TIME => self::HIT_LIMIT / 3, + ])); + + $this->assertEquals(self::HIT_LIMIT / 3, $this->throttler->count()); + } + + public function testGetRetryTimeoutPreLimit() + { + $this->mockTimePassed(self::TIME_LIMIT + 1); + + $this->cacheAdapter->shouldReceive('get') + ->with('key') + ->andReturn(serialize([self::INITIAL_TIME - self::TIME_LIMIT - 1 => self::HIT_LIMIT + 1])); + + $this->assertEquals(0, $this->throttler->getRetryTimeout()); + } + + public function testGetRetryTimeoutPostLimit() + { + $this->mockTimePassed(1); + + $this->cacheAdapter->shouldReceive('get') + ->with('key') + ->andReturn(serialize([ + self::INITIAL_TIME => 1, + self::INITIAL_TIME + 1 => 1, // <-- This is the timestamp which should expire before can be retried + self::INITIAL_TIME + self::TIME_LIMIT - 1 => self::HIT_LIMIT - 2 + ])); + + $this->assertEquals(1e3 * (self::TIME_LIMIT - 1), $this->throttler->getRetryTimeout()); + } + + /** + * @inheritdoc + */ + protected function createThrottler($key) + { + return new MovingWindowThrottler( + $this->cacheAdapter, + $this->timeAdapter, + $key, + self::HIT_LIMIT, + self::TIME_LIMIT, + self::CACHE_TTL + ); + } +} diff --git a/tests/Throttle/Throttler/RetrialQueueThrottlerTest.php b/tests/Throttle/Throttler/RetrialQueueThrottlerTest.php new file mode 100644 index 0000000..38ff419 --- /dev/null +++ b/tests/Throttle/Throttler/RetrialQueueThrottlerTest.php @@ -0,0 +1,103 @@ +timeAdapter = M::mock(TimeAdapterInterface::class); + $this->cacheAdapter = M::mock(CacheAdapterInterface::class); + + $this->internalThrottler = M::mock(RetriableThrottlerInterface::class); + $this->internalThrottler->shouldReceive('getLimit')->andReturn(self::HIT_LIMIT); + $this->internalThrottler->shouldReceive('getTime')->andReturn(self::TIME_LIMIT); + + $this->throttler = new RetrialQueueThrottler($this->internalThrottler, $this->timeAdapter); + } + + public function testAccess() + { + $this->internalThrottler->shouldReceive('check')->andReturn(true); + $this->internalThrottler->shouldReceive('getRetryTimeout')->andReturn(0); + $this->internalThrottler->shouldReceive('hit')->once(); + + $this->timeAdapter->shouldNotReceive('usleep'); + + $this->assertTrue($this->throttler->access()); + } + + public function testHitBelowThreshold() + { + $this->internalThrottler->shouldReceive('getRetryTimeout')->andReturn(0); + $this->internalThrottler->shouldReceive('hit')->once()->andReturnSelf(); + + $this->timeAdapter->shouldNotReceive('usleep'); + + $this->assertEquals($this->internalThrottler, $this->throttler->hit()); + } + + public function testHitOnThreshold() + { + $this->internalThrottler->shouldReceive('getRetryTimeout')->andReturn(1e3); + $this->internalThrottler->shouldReceive('hit')->once()->andReturnSelf(); + + $this->timeAdapter->shouldReceive('usleep')->with(1e6)->once(); + + $this->assertEquals($this->internalThrottler, $this->throttler->hit()); + } + + public function testClear() + { + $this->internalThrottler->shouldReceive('clear')->once(); + + $this->throttler->clear(); + } + + public function testCount() + { + $this->internalThrottler->shouldReceive('count')->andReturn(1); + + self::assertEquals(1, $this->throttler->count()); + } + + public function testCheck() + { + $this->internalThrottler->shouldReceive('check')->andReturn(true); + + self::assertTrue($this->throttler->check()); + } +}