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());
+ }
+}