├── .gitignore ├── .codeclimate.yml ├── tests ├── php.ini ├── bootstrap.php ├── phpunit.xml └── RateLimitTest.php ├── .php_cs.dist ├── src ├── Adapter │ ├── APCu.php │ ├── APC.php │ ├── Predis.php │ ├── Redis.php │ ├── Memcached.php │ └── Stash.php ├── Adapter.php └── RateLimit.php ├── .travis.yml ├── composer.json ├── psalm.xml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | composer.list 3 | vendor 4 | .idea -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | PHP: true 3 | exclude_paths: 4 | - tests/* 5 | -------------------------------------------------------------------------------- /tests/php.ini: -------------------------------------------------------------------------------- 1 | extension=redis.so 2 | extension=apcu.so 3 | apc.enable_cli=1 4 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | exclude('vendor') 5 | ->in(__DIR__); 6 | 7 | return PhpCsFixer\Config::create() 8 | ->setFinder($finder) 9 | ->setRules(array( 10 | '@PSR2' => true, 11 | )); 12 | -------------------------------------------------------------------------------- /src/Adapter/APCu.php: -------------------------------------------------------------------------------- 1 | 7 | * @date June 7, 2016 8 | */ 9 | class APCu extends \PalePurple\RateLimit\Adapter 10 | { 11 | public function set($key, $value, $ttl) 12 | { 13 | return apcu_store($key, $value, $ttl); 14 | } 15 | 16 | public function get($key) 17 | { 18 | return apcu_fetch($key); 19 | } 20 | 21 | public function exists($key) 22 | { 23 | return apcu_exists($key); 24 | } 25 | 26 | public function del($key) 27 | { 28 | return (bool) apcu_delete($key); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Adapter/APC.php: -------------------------------------------------------------------------------- 1 | 9 | * @date May 16, 2015 10 | */ 11 | class APC extends Adapter 12 | { 13 | public function set($key, $value, $ttl) 14 | { 15 | return apc_store($key, $value, $ttl); 16 | } 17 | 18 | public function get($key) 19 | { 20 | return apc_fetch($key); 21 | } 22 | 23 | public function exists($key) 24 | { 25 | return apc_exists($key); 26 | } 27 | 28 | public function del($key) 29 | { 30 | return apc_delete($key); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 7.1 4 | - 7.2 5 | - 7.3 6 | 7 | cache: 8 | directories: 9 | - vendor 10 | - $HOME/.composer/cache 11 | 12 | services: 13 | - redis-server 14 | 15 | before_script: 16 | - if [[ $TRAVIS_PHP_VERSION = 7.1 ]] ; then pecl config-set preferred_state beta; echo yes | pecl install apcu || true ; fi; 17 | - if [[ $TRAVIS_PHP_VERSION = 7.2 ]] ; then pecl config-set preferred_state beta; echo yes | pecl install apcu || true ; fi; 18 | - if [[ $TRAVIS_PHP_VERSION = 7.3 ]] ; then pecl config-set preferred_state beta; echo yes | pecl install apcu || true ; fi; 19 | - phpenv config-add tests/php.ini 20 | 21 | install: 22 | - composer install 23 | script: 24 | - composer build 25 | -------------------------------------------------------------------------------- /src/Adapter.php: -------------------------------------------------------------------------------- 1 | 7 | * @date May 16, 2015 8 | */ 9 | abstract class Adapter 10 | { 11 | /** 12 | * @return bool 13 | * @param string $key 14 | * @param float $value 15 | * @param int $ttl 16 | */ 17 | abstract public function set($key, $value, $ttl); 18 | 19 | /** 20 | * @param string $key 21 | * @return float 22 | */ 23 | abstract public function get($key); 24 | 25 | /** 26 | * @param string $key 27 | * @return bool 28 | */ 29 | abstract public function exists($key); 30 | 31 | /** 32 | * @return bool 33 | * @param string $key 34 | */ 35 | abstract public function del($key); 36 | } 37 | -------------------------------------------------------------------------------- /tests/phpunit.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./../src/ 19 | 20 | 21 | ./../vendor 22 | ./ 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Adapter/Predis.php: -------------------------------------------------------------------------------- 1 | redis = $client; 19 | } 20 | 21 | 22 | public function set($key, $value, $ttl) 23 | { 24 | return $this->redis->set($key, (string) $value, "ex", $ttl); 25 | } 26 | 27 | /** 28 | * @return float 29 | */ 30 | public function get($key) 31 | { 32 | return (float)$this->redis->get($key); 33 | } 34 | 35 | public function exists($key) 36 | { 37 | return (bool)$this->redis->exists($key); 38 | } 39 | 40 | public function del($key) 41 | { 42 | return (bool)$this->redis->del([$key]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Adapter/Redis.php: -------------------------------------------------------------------------------- 1 | 7 | * @date May 16, 2015 8 | */ 9 | class Redis extends \PalePurple\RateLimit\Adapter 10 | { 11 | 12 | /** 13 | * @var \Redis 14 | */ 15 | protected $redis; 16 | 17 | /** 18 | * Redis constructor. 19 | * @param \Redis $redis 20 | */ 21 | public function __construct(\Redis $redis) 22 | { 23 | $this->redis = $redis; 24 | } 25 | 26 | public function set($key, $value, $ttl) 27 | { 28 | return $this->redis->set($key, (string)$value, $ttl); 29 | } 30 | 31 | /** 32 | * @return float 33 | */ 34 | public function get($key) 35 | { 36 | return (float)$this->redis->get($key); 37 | } 38 | 39 | public function exists($key) 40 | { 41 | return $this->redis->exists($key) == true; 42 | } 43 | 44 | public function del($key) 45 | { 46 | return $this->redis->del($key) > 0; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Adapter/Memcached.php: -------------------------------------------------------------------------------- 1 | memcached = $memcached; 16 | } 17 | 18 | public function set($key, $value, $ttl) 19 | { 20 | return $this->memcached->set($key, $value, $ttl); 21 | } 22 | 23 | /** 24 | * @return float 25 | * @param string $key 26 | */ 27 | public function get($key) 28 | { 29 | $val = $this->_get($key); 30 | return (float) $val; 31 | } 32 | 33 | /** 34 | * @return bool|float 35 | * @param string $key 36 | */ 37 | private function _get($key) 38 | { 39 | return $this->memcached->get($key); 40 | } 41 | 42 | public function exists($key) 43 | { 44 | $val = $this->_get($key); 45 | return $val !== false; 46 | } 47 | 48 | public function del($key) 49 | { 50 | return $this->memcached->delete($key); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Adapter/Stash.php: -------------------------------------------------------------------------------- 1 | pool = $pool; 23 | } 24 | 25 | public function get($key) 26 | { 27 | $item = $this->pool->getItem($key); 28 | $item->setInvalidationMethod(Invalidation::OLD); 29 | 30 | if ($item->isHit()) { 31 | return $item->get(); 32 | } 33 | return null; 34 | } 35 | 36 | public function set($key, $value, $ttl) 37 | { 38 | $item = $this->pool->getItem($key); 39 | $item->set($value); 40 | $item->expiresAfter($ttl); 41 | return $item->save(); 42 | } 43 | 44 | public function exists($key) 45 | { 46 | $item = $this->pool->getItem($key); 47 | $item->setInvalidationMethod(Invalidation::OLD); 48 | return $item->isHit(); 49 | } 50 | 51 | public function del($key) 52 | { 53 | return $this->pool->deleteItem($key); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "library", 3 | "name": "touhonoob/rate-limit", 4 | "description": "PHP rate limiting library with Token Bucket Algorithm", 5 | "version": "2.0.2", 6 | "require": { 7 | "php": ">=5.6" 8 | }, 9 | "require-dev": { 10 | "friendsofphp/php-cs-fixer": "^2.7", 11 | "jakub-onderka/php-parallel-lint": "^1.0", 12 | "php" : ">7.0", 13 | "phpunit/phpunit": "^6.0", 14 | "predis/predis": "^1.1", 15 | "psr/cache": "^1.0", 16 | "tedivm/stash": "^0.14", 17 | "vimeo/psalm": "*" 18 | }, 19 | "suggest": { 20 | "tedivm/stash": "^0.14", 21 | "predis/predis": "^1.1", 22 | "ext-redis": "^2.2", 23 | "ext-apc": "^4.0", 24 | "ext-apcu": "^4.0" 25 | }, 26 | "license": "MIT", 27 | "authors": [ 28 | { 29 | "name": "David Goodwin", 30 | "email": "david@palepurple.co.uk" 31 | }, 32 | { 33 | "name": "Peter Chung", 34 | "email": "touhonoob@gmail.com" 35 | } 36 | ], 37 | "autoload": { 38 | "psr-4": { 39 | "PalePurple\\RateLimit\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "PalePurple\\RateLimit\\Tests\\": "tests/" 45 | } 46 | }, 47 | "scripts": { 48 | "build" : [ "@lint", "@check-format", "@psalm", "@test" ], 49 | "psalm" : "@php ./vendor/bin/psalm src", 50 | "lint": "@php ./vendor/bin/parallel-lint --exclude vendor/ .", 51 | "check-format": "@php ./vendor/bin/php-cs-fixer fix --ansi --dry-run --diff", 52 | "format": "@php ./vendor/bin/php-cs-fixer fix --ansi", 53 | "test": "@php ./vendor/bin/phpunit -c tests/phpunit.xml tests/ " 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RateLimit 2 | 3 | [![Build Status](https://travis-ci.org/DavidGoodwin/RateLimit.svg)](https://travis-ci.org/DavidGoodwin/RateLimit/) 4 | 5 | PHP Rate Limiting Library With Token Bucket Algorithm with minimal external dependencies. 6 | 7 | # Installation 8 | 9 | ```composer require palepurple/rate-limit``` 10 | 11 | # Storage Adapters 12 | 13 | The RateLimiter needs to know where to get/set data. 14 | 15 | Depending on which adapter you install, you may need to install additional libraries (predis/predis or tedivm/stash) or PHP extensions (e.g. Redis, Memcache, APC) 16 | 17 | 18 | - [APCu](https://pecl.php.net/package/APCu) 19 | - [Redis](https://pecl.php.net/package/redis) or [Predis](https://github.com/nrk/predis) 20 | - [Stash](http://www.stashphp.com) (This supports many drivers - see http://www.stashphp.com/Drivers.html ) 21 | - [Memcached](http://php.net/manual/en/intro.memcached.php) 22 | 23 | 24 | # Example 25 | ````php 26 | require 'vendor/autoload.php'; 27 | 28 | use \PalePurple\RateLimit\RateLimit; 29 | use \PalePurple\RateLimit\Adapter\APC as APCAdapter; 30 | use \PalePurple\RateLimit\Adapter\Redis as RedisAdapter; 31 | use \PalePurple\RateLimit\Adapter\Predis as PredisAdapter; 32 | use \PalePurple\RateLimit\Adapter\Memcached as MemcachedAdapter; 33 | use \PalePurple\RateLimit\Adapter\Stash as StashAdapter; 34 | 35 | 36 | $adapter = new APCAdapter(); // Use APC as Storage 37 | // Alternatives: 38 | // 39 | // $adapter = new RedisAdapter((new \Redis()->connect('localhost'))); // Use Redis as Storage 40 | // 41 | // $adapter = new PredisAdapter((new \Predis\Predis())->connect('localhost')); // Use Predis as Storage 42 | // 43 | // $memcache = new \Memcached(); 44 | // $memcache->addServer('localhost', 11211); 45 | // $adapter = new MemcacheAdapter($memcache); 46 | // 47 | // $stash = new \Stash\Pool(new \Stash\Driver\FileSystem()); 48 | // $adapter = new StashAdapter($stash); 49 | 50 | $rateLimit = new RateLimit("myratelimit", 100, 3600, $adapter); // 100 Requests / Hour 51 | 52 | $id = $_SERVER['REMOTE_ADDR']; // Use client IP as identity 53 | if ($rateLimit->check($id)) { 54 | echo "passed"; 55 | } else { 56 | echo "rate limit exceeded"; 57 | } 58 | ```` 59 | 60 | # Installing via Composer 61 | ````shell 62 | curl -sS https://getcomposer.org/installer | php 63 | composer.phar require palepurple/rate-limit 64 | ```` 65 | 66 | # References 67 | 68 | - [stackoverflow post about Rate Limiting](http://stackoverflow.com/a/668327/670662) 69 | - [wikipedia token bucket](http://en.wikipedia.org/wiki/Token_bucket) 70 | - [this code is forked from here...](https://github.com/touhonoob/RateLimit) 71 | -------------------------------------------------------------------------------- /src/RateLimit.php: -------------------------------------------------------------------------------- 1 | 7 | * @date May 16, 2015 8 | */ 9 | class RateLimit 10 | { 11 | 12 | /** 13 | * 14 | * @var string 15 | */ 16 | protected $name; 17 | 18 | /** 19 | * 20 | * @var int 21 | */ 22 | protected $maxRequests; 23 | 24 | /** 25 | * 26 | * @var int 27 | */ 28 | protected $period; 29 | 30 | /** 31 | * @var Adapter 32 | */ 33 | private $adapter; 34 | 35 | /** 36 | * RateLimit constructor. 37 | * @param string $name - prefix used in storage keys. 38 | * @param int $maxRequests 39 | * @param int $period seconds 40 | * @param Adapter $adapter - storage adapter 41 | */ 42 | public function __construct($name, $maxRequests, $period, Adapter $adapter) 43 | { 44 | $this->name = $name; 45 | $this->maxRequests = $maxRequests; 46 | $this->period = $period; 47 | $this->adapter = $adapter; 48 | } 49 | 50 | /** 51 | * Rate Limiting 52 | * http://stackoverflow.com/a/668327/670662 53 | * @param string $id 54 | * @param float $use 55 | * @return boolean 56 | */ 57 | public function check($id, $use = 1.0) 58 | { 59 | $rate = $this->maxRequests / $this->period; 60 | 61 | $t_key = $this->keyTime($id); 62 | $a_key = $this->keyAllow($id); 63 | 64 | if (!$this->adapter->exists($t_key)) { 65 | // first hit; setup storage; allow. 66 | $this->adapter->set($t_key, time(), $this->period); 67 | $this->adapter->set($a_key, ($this->maxRequests - $use), $this->period); 68 | return true; 69 | } 70 | 71 | $c_time = time(); 72 | 73 | $time_passed = $c_time - $this->adapter->get($t_key); 74 | $this->adapter->set($t_key, $c_time, $this->period); 75 | 76 | $allowance = $this->adapter->get($a_key); 77 | $allowance += $time_passed * $rate; 78 | 79 | if ($allowance > $this->maxRequests) { 80 | $allowance = $this->maxRequests; // throttle 81 | } 82 | 83 | 84 | if ($allowance < $use) { 85 | // need to wait for more 'tokens' to be in the bucket. 86 | $this->adapter->set($a_key, $allowance, $this->period); 87 | return false; 88 | } 89 | 90 | 91 | $this->adapter->set($a_key, $allowance - $use, $this->period); 92 | return true; 93 | } 94 | 95 | /** 96 | * @deprecated use getAllowance() instead. 97 | * @param string $id 98 | * @return int 99 | */ 100 | public function getAllow($id) 101 | { 102 | return $this->getAllowance($id); 103 | } 104 | 105 | 106 | /** 107 | * Get allowance left. 108 | * 109 | * @param string $id 110 | * @return int number of requests that can be made before hitting a limit. 111 | */ 112 | public function getAllowance($id) 113 | { 114 | $this->check($id, 0.0); 115 | 116 | $a_key = $this->keyAllow($id); 117 | 118 | if (!$this->adapter->exists($a_key)) { 119 | return $this->maxRequests; 120 | } 121 | return (int) max(0, floor($this->adapter->get($a_key))); 122 | } 123 | 124 | /** 125 | * Purge rate limit record for $id 126 | * @param string $id 127 | * @return void 128 | */ 129 | public function purge($id) 130 | { 131 | $this->adapter->del($this->keyTime($id)); 132 | $this->adapter->del($this->keyAllow($id)); 133 | } 134 | 135 | /** 136 | * @return string 137 | * @param string $id 138 | */ 139 | private function keyTime($id) 140 | { 141 | return $this->name . ":" . $id . ":time"; 142 | } 143 | 144 | /** 145 | * @return string 146 | * @param string $id 147 | */ 148 | private function keyAllow($id) 149 | { 150 | return $this->name . ":" . $id . ":allow"; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/RateLimitTest.php: -------------------------------------------------------------------------------- 1 | 11 | * @date May 16, 2015 12 | */ 13 | class RateLimitTest extends TestCase 14 | { 15 | const NAME = "RateLimitTest"; 16 | const MAX_REQUESTS = 10; 17 | const PERIOD = 2; 18 | 19 | /** 20 | * @requires extension apc 21 | */ 22 | public function testCheckAPC() 23 | { 24 | if (!extension_loaded('apc')) { 25 | $this->markTestSkipped("apc extension not installed"); 26 | } 27 | if (ini_get('apc.enable_cli') == 0) { 28 | $this->markTestSkipped("apc.enable_cli != 1; can't change at runtime"); 29 | } 30 | 31 | $adapter = new Adapter\APC(); 32 | $this->check($adapter); 33 | } 34 | 35 | /** 36 | * @requires extension apcu 37 | */ 38 | public function testCheckAPCu() 39 | { 40 | if (!extension_loaded('apcu')) { 41 | $this->markTestSkipped("apcu extension not installed"); 42 | } 43 | if (ini_get('apc.enable_cli') == 0) { 44 | $this->markTestSkipped("apc.enable_cli != 1; can't change at runtime"); 45 | } 46 | $adapter = new Adapter\APCu(); 47 | $this->check($adapter); 48 | } 49 | 50 | /** 51 | * @requires extension redis 52 | */ 53 | public function testCheckRedis() 54 | { 55 | if (!extension_loaded('redis')) { 56 | $this->markTestSkipped("redis extension not installed"); 57 | } 58 | $redis = new \Redis(); 59 | $redis->connect('localhost'); 60 | $redis->flushDB(); // clear redis db 61 | 62 | $adapter = new Adapter\Redis($redis); 63 | $this->check($adapter); 64 | } 65 | 66 | public function testCheckPredis() 67 | { 68 | $predis = new \Predis\Client(); 69 | $predis->connect("localhost"); 70 | $predis->flushdb(); // clear redis db. 71 | $adapter = new Adapter\Predis($predis); 72 | $this->check($adapter); 73 | } 74 | 75 | public function testCheckStash() 76 | { 77 | $stash = new \Stash\Pool(); // ephermeral driver by default 78 | $stash->clear(); 79 | $adapter = new Adapter\Stash($stash); 80 | $this->check($adapter); 81 | } 82 | 83 | public function testCheckMemcached() 84 | { 85 | if (!extension_loaded('memcached')) { 86 | $this->markTestSkipped("memcached extension not installed"); 87 | } 88 | $m = new \Memcached(); 89 | $m->addServer('localhost', 11211); 90 | $adapter = new Adapter\Memcached($m); 91 | $this->check($adapter); 92 | } 93 | 94 | 95 | private function check($adapter) 96 | { 97 | $label = uniqid("label", true); // should stop storage conflicts if tests are running in parallel. 98 | $rateLimit = $this->getRateLimit($adapter); 99 | 100 | $rateLimit->purge($label); // make sure a previous failed test doesn't mess up this one. 101 | 102 | $this->assertEquals(self::MAX_REQUESTS, $rateLimit->getAllowance($label)); 103 | 104 | // All should work, but bucket will be empty at the end. 105 | for ($i = 0; $i < self::MAX_REQUESTS; $i++) { 106 | // Calling check reduces the counter each time. 107 | $this->assertEquals(self::MAX_REQUESTS - $i, $rateLimit->getAllowance($label)); 108 | $this->assertTrue($rateLimit->check($label)); 109 | } 110 | 111 | // bucket empty. 112 | $this->assertFalse($rateLimit->check($label), "Bucket should be empty"); 113 | $this->assertEquals(0, $rateLimit->getAllowance($label), "Bucket should be empty"); 114 | 115 | //Wait for PERIOD seconds, bucket should refill. 116 | sleep(self::PERIOD); 117 | $this->assertEquals(self::MAX_REQUESTS, $rateLimit->getAllowance($label)); 118 | $this->assertTrue($rateLimit->check($label)); 119 | } 120 | 121 | private function getRateLimit(Adapter $adapter) 122 | { 123 | return new RateLimit(self::NAME . uniqid(), self::MAX_REQUESTS, self::PERIOD, $adapter); 124 | } 125 | } 126 | --------------------------------------------------------------------------------