├── .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 | [](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 |
--------------------------------------------------------------------------------