├── tests
├── php-travis.ini
├── storage
│ ├── IPCStorageTest.php
│ ├── PHPRedisStorageTest.php
│ ├── PredisStorageTest.php
│ ├── FileStorageTest.php
│ ├── MemcachedStorageTest.php
│ ├── SharedStorageTest.php
│ ├── PDOStorageTest.php
│ └── StorageTest.php
├── util
│ ├── DoublePackerTest.php
│ └── TokenConverterTest.php
├── RateTest.php
├── BlockingConsumerTest.php
└── TokenBucketTest.php
├── phpunit.xml
├── classes
├── TokenBucketException.php
├── TimeoutException.php
├── storage
│ ├── StorageException.php
│ ├── scope
│ │ ├── SessionScope.php
│ │ ├── GlobalScope.php
│ │ └── RequestScope.php
│ ├── SingleProcessStorage.php
│ ├── SessionStorage.php
│ ├── Storage.php
│ ├── MemcacheStorage.php
│ ├── PredisStorage.php
│ ├── PHPRedisStorage.php
│ ├── IPCStorage.php
│ ├── FileStorage.php
│ ├── MemcachedStorage.php
│ └── PDOStorage.php
├── util
│ ├── DoublePacker.php
│ └── TokenConverter.php
├── Rate.php
├── BlockingConsumer.php
└── TokenBucket.php
├── LICENSE
├── composer.json
├── .travis.yml
└── README.md
/tests/php-travis.ini:
--------------------------------------------------------------------------------
1 | extension = "memcached.so"
2 | extension = "redis.so"
3 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
5 |
6 | tests
7 |
8 |
--------------------------------------------------------------------------------
/classes/TokenBucketException.php:
--------------------------------------------------------------------------------
1 |
9 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
10 | * @license WTFPL
11 | */
12 | class TokenBucketException extends \Exception
13 | {
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/classes/TimeoutException.php:
--------------------------------------------------------------------------------
1 |
9 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
10 | * @license WTFPL
11 | */
12 | final class TimeoutException extends TokenBucketException
13 | {
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/classes/storage/StorageException.php:
--------------------------------------------------------------------------------
1 |
11 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
12 | * @license WTFPL
13 | */
14 | final class StorageException extends TokenBucketException
15 | {
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2 | Version 2, December 2004
3 |
4 | Copyright (C) 2004 Sam Hocevar
5 |
6 | Everyone is permitted to copy and distribute verbatim or modified
7 | copies of this license document, and changing it is allowed as long
8 | as the name is changed.
9 |
10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12 |
13 | 0. You just DO WHAT THE FUCK YOU WANT TO.
14 |
15 |
--------------------------------------------------------------------------------
/classes/storage/scope/SessionScope.php:
--------------------------------------------------------------------------------
1 |
14 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
15 | * @license WTFPL
16 | */
17 | interface SessionScope
18 | {
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/classes/storage/scope/GlobalScope.php:
--------------------------------------------------------------------------------
1 |
15 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
16 | * @license WTFPL
17 | */
18 | interface GlobalScope
19 | {
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/classes/storage/scope/RequestScope.php:
--------------------------------------------------------------------------------
1 |
15 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
16 | * @license WTFPL
17 | */
18 | interface RequestScope
19 | {
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bandwidth-throttle/token-bucket",
3 | "type": "library",
4 | "description": "Implementation of the Token Bucket algorithm.",
5 | "keywords": ["token bucket", "bandwidth", "rate limit", "throttle", "throttling", "rate limiting"],
6 | "homepage": "https://github.com/bandwidth-throttle/token-bucket",
7 | "license": "WTFPL",
8 | "authors": [
9 | {
10 | "name": "Markus Malkusch",
11 | "email": "markus@malkusch.de",
12 | "homepage": "http://markus.malkusch.de",
13 | "role": "Developer"
14 | }
15 | ],
16 | "autoload": {
17 | "psr-4": {"bandwidthThrottle\\tokenBucket\\": "classes/"}
18 | },
19 | "require": {
20 | "php": ">=5.6",
21 | "malkusch/lock": "^1",
22 | "ext-bcmath":"*"
23 | },
24 | "require-dev": {
25 | "ext-redis": "^2.2.4|^3",
26 | "predis/predis": "^1",
27 | "phpunit/phpunit": "^5",
28 | "mikey179/vfsStream": "^1.5.0",
29 | "php-mock/php-mock-phpunit": "^1"
30 | },
31 | "archive": {
32 | "exclude": ["/tests"]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 |
3 | sudo: false
4 |
5 | cache:
6 | directories:
7 | - $HOME/.composer/cache
8 |
9 | php:
10 | - 7.1
11 | - 7.0
12 | - 5.6
13 | - hhvm
14 |
15 | services:
16 | - memcached
17 | - redis-server
18 |
19 | matrix:
20 | fast_finish: true
21 | allow_failures:
22 | - php: hhvm
23 |
24 | env:
25 | global:
26 | - MYSQL_DSN="mysql:host=localhost;dbname=test"
27 | - MYSQL_USER=travis
28 | - PGSQL_DSN="pgsql:host=localhost;dbname=test;"
29 | - PGSQL_USER=postgres
30 | - MEMCACHE_HOST=localhost
31 | - REDIS_URI=redis://localhost
32 |
33 | before_install:
34 | - phpenv config-add tests/php-travis.ini
35 |
36 | install:
37 | - composer require squizlabs/php_codesniffer phpmd/phpmd cundd/test-flight
38 |
39 | before_script:
40 | - mysql -e 'create database test;'
41 | - psql -c 'create database test;' -U postgres
42 |
43 | script:
44 | - vendor/bin/phpunit
45 | - vendor/bin/test-flight README.md
46 | - vendor/bin/test-flight classes/
47 | - vendor/bin/phpcs --standard=PSR2 classes/ tests/
48 | - vendor/bin/phpmd classes/ text cleancode,codesize,controversial,design,naming,unusedcode
49 |
50 |
--------------------------------------------------------------------------------
/classes/util/DoublePacker.php:
--------------------------------------------------------------------------------
1 |
11 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
12 | * @license WTFPL
13 | * @internal
14 | */
15 | final class DoublePacker
16 | {
17 |
18 | /**
19 | * Packs a 64 bit double into an 8 byte string.
20 | *
21 | * @param double $double 64 bit double
22 | * @return string packed 8 byte string representation
23 | */
24 | public static function pack($double)
25 | {
26 | $string = pack("d", $double);
27 | assert(8 === strlen($string));
28 | return $string;
29 | }
30 |
31 | /**
32 | * Unpacks a 64 bit double from an 8 byte string.
33 | *
34 | * @param string $string packed 8 byte string representation.
35 | * @return double unpacked 64 bit double
36 | * @throws StorageException conversion error
37 | */
38 | public static function unpack($string)
39 | {
40 | if (strlen($string) !== 8) {
41 | throw new StorageException("The string is not 64 bit long.");
42 | }
43 | $unpack = unpack("d", $string);
44 | if (!is_array($unpack) || !array_key_exists(1, $unpack)) {
45 | throw new StorageException("Could not unpack string.");
46 | }
47 | return $unpack[1];
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/classes/storage/SingleProcessStorage.php:
--------------------------------------------------------------------------------
1 |
15 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
16 | * @license WTFPL
17 | */
18 | final class SingleProcessStorage implements Storage, RequestScope
19 | {
20 |
21 | /**
22 | * @var Mutex The mutex.
23 | */
24 | private $mutex;
25 |
26 | /**
27 | * @var double The microtime.
28 | */
29 | private $microtime;
30 |
31 | /**
32 | * Initialization.
33 | */
34 | public function __construct()
35 | {
36 | $this->mutex = new NoMutex();
37 | }
38 |
39 | public function isBootstrapped()
40 | {
41 | return ! is_null($this->microtime);
42 | }
43 |
44 | public function bootstrap($microtime)
45 | {
46 | $this->setMicrotime($microtime);
47 | }
48 |
49 | public function remove()
50 | {
51 | $this->microtime = null;
52 | }
53 |
54 | public function setMicrotime($microtime)
55 | {
56 | $this->microtime = $microtime;
57 | }
58 |
59 | public function getMicrotime()
60 | {
61 | return $this->microtime;
62 | }
63 |
64 | public function getMutex()
65 | {
66 | return $this->mutex;
67 | }
68 |
69 | public function letMicrotimeUnchanged()
70 | {
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/classes/util/TokenConverter.php:
--------------------------------------------------------------------------------
1 |
11 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
12 | * @license WTFPL
13 | * @internal
14 | */
15 | final class TokenConverter
16 | {
17 |
18 | /**
19 | * @var Rate The rate.
20 | */
21 | private $rate;
22 |
23 | /**
24 | * @var int precision scale for bc_* operations.
25 | */
26 | private $bcScale = 8;
27 |
28 | /**
29 | * Sets the token rate.
30 | *
31 | * @param int $rate The rate.
32 | */
33 | public function __construct(Rate $rate)
34 | {
35 | $this->rate = $rate;
36 | }
37 |
38 | /**
39 | * Converts a duration of seconds into an amount of tokens.
40 | *
41 | * @param double $seconds The duration in seconds.
42 | * @return int The amount of tokens.
43 | */
44 | public function convertSecondsToTokens($seconds)
45 | {
46 | return (int) ($seconds * $this->rate->getTokensPerSecond());
47 | }
48 |
49 | /**
50 | * Converts an amount of tokens into a duration of seconds.
51 | *
52 | * @param int $tokens The amount of tokens.
53 | * @return double The seconds.
54 | */
55 | public function convertTokensToSeconds($tokens)
56 | {
57 | return $tokens / $this->rate->getTokensPerSecond();
58 | }
59 |
60 | /**
61 | * Converts an amount of tokens into a timestamp.
62 | *
63 | * @param int $tokens The amount of tokens.
64 | * @return double The timestamp.
65 | */
66 | public function convertTokensToMicrotime($tokens)
67 | {
68 | return microtime(true) - $this->convertTokensToSeconds($tokens);
69 | }
70 |
71 | /**
72 | * Converts a timestamp into tokens.
73 | *
74 | * @param double $microtime The timestamp.
75 | *
76 | * @return int The tokens.
77 | */
78 | public function convertMicrotimeToTokens($microtime)
79 | {
80 | $delta = bcsub(microtime(true), $microtime, $this->bcScale);
81 | return $this->convertSecondsToTokens($delta);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/classes/storage/SessionStorage.php:
--------------------------------------------------------------------------------
1 |
17 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
18 | * @license WTFPL
19 | */
20 | final class SessionStorage implements Storage, SessionScope
21 | {
22 |
23 | /**
24 | * @var Mutex The mutex.
25 | */
26 | private $mutex;
27 |
28 | /**
29 | * @var String The session key for this bucket.
30 | */
31 | private $key;
32 |
33 | /**
34 | * @internal
35 | */
36 | const SESSION_NAMESPACE = "TokenBucket_";
37 |
38 | /**
39 | * Sets the bucket's name.
40 | *
41 | * @param string $name The bucket's name.
42 | */
43 | public function __construct($name)
44 | {
45 | $this->mutex = new NoMutex();
46 | $this->key = self::SESSION_NAMESPACE . $name;
47 | }
48 |
49 | public function getMutex()
50 | {
51 | return $this->mutex;
52 | }
53 |
54 | public function bootstrap($microtime)
55 | {
56 | $this->setMicrotime($microtime);
57 | }
58 |
59 | /**
60 | * @SuppressWarnings(PHPMD)
61 | * @internal
62 | */
63 | public function getMicrotime()
64 | {
65 | return $_SESSION[$this->key];
66 | }
67 |
68 | /**
69 | * @SuppressWarnings(PHPMD)
70 | * @internal
71 | */
72 | public function isBootstrapped()
73 | {
74 | return isset($_SESSION[$this->key]);
75 | }
76 |
77 | /**
78 | * @SuppressWarnings(PHPMD)
79 | * @internal
80 | */
81 | public function remove()
82 | {
83 | unset($_SESSION[$this->key]);
84 | }
85 |
86 | /**
87 | * @SuppressWarnings(PHPMD)
88 | * @internal
89 | */
90 | public function setMicrotime($microtime)
91 | {
92 | $_SESSION[$this->key] = $microtime;
93 | }
94 |
95 | public function letMicrotimeUnchanged()
96 | {
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/tests/storage/IPCStorageTest.php:
--------------------------------------------------------------------------------
1 |
9 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
10 | * @license WTFPL
11 | * @see IPCStorage
12 | */
13 | class IPCStorageTest extends \PHPUnit_Framework_TestCase
14 | {
15 |
16 | /**
17 | * Tests building fails for an invalid key.
18 | *
19 | * @test
20 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
21 | */
22 | public function testBuildFailsForInvalidKey()
23 | {
24 | @new IPCStorage("invalid");
25 | }
26 |
27 | /**
28 | * Tests remove() fails.
29 | *
30 | * @test
31 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
32 | * @expectedExceptionMessage Could not release shared memory.
33 | */
34 | public function testRemoveFails()
35 | {
36 | $storage = new IPCStorage(ftok(__FILE__, "a"));
37 | $storage->remove();
38 | @$storage->remove();
39 | }
40 |
41 | /**
42 | * Tests removing semaphore fails.
43 | *
44 | * @test
45 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
46 | * @expectedExceptionMessage Could not remove semaphore.
47 | */
48 | public function testfailRemovingSemaphore()
49 | {
50 | $key = ftok(__FILE__, "a");
51 | $storage = new IPCStorage($key);
52 |
53 | sem_remove(sem_get($key));
54 | @$storage->remove();
55 | }
56 |
57 | /**
58 | * Tests setMicrotime() fails.
59 | *
60 | * @test
61 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
62 | */
63 | public function testSetMicrotimeFails()
64 | {
65 | $storage = new IPCStorage(ftok(__FILE__, "a"));
66 | $storage->remove();
67 | @$storage->setMicrotime(123);
68 | }
69 |
70 | /**
71 | * Tests getMicrotime() fails.
72 | *
73 | * @test
74 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
75 | */
76 | public function testGetMicrotimeFails()
77 | {
78 | $storage = new IPCStorage(ftok(__FILE__, "b"));
79 | @$storage->getMicrotime();
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/classes/Rate.php:
--------------------------------------------------------------------------------
1 |
9 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
10 | * @license WTFPL
11 | */
12 | final class Rate
13 | {
14 |
15 | const MICROSECOND = "microsecond";
16 | const MILLISECOND = "millisecond";
17 | const SECOND = "second";
18 | const MINUTE = "minute";
19 | const HOUR = "hour";
20 | const DAY = "day";
21 | const WEEK = "week";
22 | const MONTH = "month";
23 | const YEAR = "year";
24 |
25 | /**
26 | * @var double[] Mapping between units and seconds
27 | */
28 | private static $unitMap = [
29 | self::MICROSECOND => 0.000001,
30 | self::MILLISECOND => 0.001,
31 | self::SECOND => 1,
32 | self::MINUTE => 60,
33 | self::HOUR => 3600,
34 | self::DAY => 86400,
35 | self::WEEK => 604800,
36 | self::MONTH => 2629743.83,
37 | self::YEAR => 31556926,
38 | ];
39 |
40 | /**
41 | * @var int The amount of tokens to produce for the unit.
42 | */
43 | private $tokens;
44 |
45 | /**
46 | * @var string The unit.
47 | */
48 | private $unit;
49 |
50 | /**
51 | * Sets the amount of tokens which will be produced per unit.
52 | *
53 | * E.g. new Rate(100, Rate::SECOND) will produce 100 tokens per second.
54 | *
55 | * @param int $tokens positive amount of tokens to produce per unit
56 | * @param string $unit unit as one of Rate's constants
57 | */
58 | public function __construct($tokens, $unit)
59 | {
60 | if (!isset(self::$unitMap[$unit])) {
61 | throw new \InvalidArgumentException("Not a valid unit.");
62 | }
63 | if ($tokens <= 0) {
64 | throw new \InvalidArgumentException("Amount of tokens should be greater then 0.");
65 | }
66 | $this->tokens = $tokens;
67 | $this->unit = $unit;
68 | }
69 |
70 | /**
71 | * Returns the rate in Tokens per second.
72 | *
73 | * @return double The rate.
74 | * @internal
75 | */
76 | public function getTokensPerSecond()
77 | {
78 | return $this->tokens / self::$unitMap[$this->unit];
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/tests/util/DoublePackerTest.php:
--------------------------------------------------------------------------------
1 |
9 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
10 | * @license WTFPL
11 | * @see DoublePacker
12 | */
13 | class DoublePackerTest extends \PHPUnit_Framework_TestCase
14 | {
15 |
16 | /**
17 | * Tests pack().
18 | *
19 | * @param string $expected The expected string.
20 | * @param double $input The input double.
21 | *
22 | * @test
23 | * @dataProvider provideTestPack
24 | */
25 | public function testPack($expected, $input)
26 | {
27 | $this->assertEquals($expected, DoublePacker::pack($input));
28 | }
29 |
30 | /**
31 | * Provides test cases for testPack().
32 | *
33 | * @return array Test cases.
34 | */
35 | public function provideTestPack()
36 | {
37 | return [
38 | [pack("d", 0) , 0],
39 | [pack("d", 0.1), 0.1],
40 | [pack("d", 1) , 1],
41 | ];
42 | }
43 |
44 | /**
45 | * Tests unpack() fails.
46 | *
47 | * @param string $input The input string.
48 | *
49 | * @test
50 | * @dataProvider provideTestUnpackFails
51 | * @expectedException \bandwidthThrottle\tokenBucket\storage\StorageException
52 | */
53 | public function testUnpackFails($input)
54 | {
55 | DoublePacker::unpack($input);
56 | }
57 |
58 | /**
59 | * Provides test cases for testUnpackFails().
60 | *
61 | * @return array Test cases.
62 | */
63 | public function provideTestUnpackFails()
64 | {
65 | return [
66 | [""],
67 | ["1234567"],
68 | ["123456789"],
69 | ];
70 | }
71 |
72 | /**
73 | * Tests unpack().
74 | *
75 | * @param double $expected The expected double.
76 | * @param string $input The input string.
77 | *
78 | * @test
79 | * @dataProvider provideTestUnpack
80 | */
81 | public function testUnpack($expected, $input)
82 | {
83 | $this->assertEquals($expected, DoublePacker::unpack($input));
84 | }
85 |
86 | /**
87 | * Provides test cases for testConvert().
88 | *
89 | * @return array Test cases.
90 | */
91 | public function provideTestUnpack()
92 | {
93 | return [
94 | [0, pack("d", 0)],
95 | [0.1, pack("d", 0.1)],
96 | [1, pack("d", 1)],
97 | [1.1, pack("d", 1.1)],
98 | ];
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/classes/storage/Storage.php:
--------------------------------------------------------------------------------
1 |
21 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
22 | * @license WTFPL
23 | */
24 | interface Storage
25 | {
26 |
27 | /**
28 | * Returns the Mutex for this storage.
29 | *
30 | * @return Mutex The mutex.
31 | * @internal
32 | */
33 | public function getMutex();
34 |
35 | /**
36 | * Returns if the storage was already bootstrapped.
37 | *
38 | * @return bool True if the storage was already bootstrapped.
39 | * @throws StorageException Checking the state of the storage failed.
40 | * @internal
41 | */
42 | public function isBootstrapped();
43 |
44 | /**
45 | * Bootstraps the storage.
46 | *
47 | * @param double $microtime The timestamp.
48 | * @throws StorageException Bootstrapping failed.
49 | * @internal
50 | */
51 | public function bootstrap($microtime);
52 |
53 | /**
54 | * Removes the storage.
55 | *
56 | * After a storage was removed you should not use that object anymore.
57 | * The only defined methods after that operations are isBootstrapped()
58 | * and bootstrap(). A call to bootstrap() results in a defined object
59 | * again.
60 | *
61 | * @throws StorageException Cleaning failed.
62 | * @internal
63 | */
64 | public function remove();
65 |
66 | /**
67 | * Stores a timestamp.
68 | *
69 | * @param double $microtime The timestamp.
70 | * @throws StorageException Writing to the storage failed.
71 | * @internal
72 | */
73 | public function setMicrotime($microtime);
74 |
75 | /**
76 | * Indicates, that there won't be any change within this transaction.
77 | *
78 | * @internal
79 | */
80 | public function letMicrotimeUnchanged();
81 |
82 | /**
83 | * Returns the stored timestamp.
84 | *
85 | * @return double The timestamp.
86 | * @throws StorageException Reading from the storage failed.
87 | * @internal
88 | */
89 | public function getMicrotime();
90 | }
91 |
--------------------------------------------------------------------------------
/tests/RateTest.php:
--------------------------------------------------------------------------------
1 |
9 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
10 | * @license WTFPL
11 | * @see Rate
12 | */
13 | class RateTest extends \PHPUnit_Framework_TestCase
14 | {
15 |
16 | /**
17 | * Tests getTokensPerSecond().
18 | *
19 | * @param double $expected The expected rate in tokens per second.
20 | * @param Rate $rate The rate.
21 | *
22 | * @test
23 | * @dataProvider provideTestGetTokensPerSecond
24 | */
25 | public function testGetTokensPerSecond($expected, Rate $rate)
26 | {
27 | $this->assertEquals($expected, $rate->getTokensPerSecond());
28 | }
29 |
30 | /**
31 | * Provides tests cases for testGetTokensPerSecond().
32 | *
33 | * @return array Test cases.
34 | */
35 | public function provideTestGetTokensPerSecond()
36 | {
37 | return [
38 | [1/60/60/24/365, new Rate(1, Rate::YEAR)],
39 | [2/60/60/24/365, new Rate(2, Rate::YEAR)],
40 | [1/2629743.83, new Rate(1, Rate::MONTH)],
41 | [2/2629743.83, new Rate(2, Rate::MONTH)],
42 | [1/60/60/24/7, new Rate(1, Rate::WEEK)],
43 | [2/60/60/24/7, new Rate(2, Rate::WEEK)],
44 | [1/60/60/24, new Rate(1, Rate::DAY)],
45 | [2/60/60/24, new Rate(2, Rate::DAY)],
46 | [1/60/60, new Rate(1, Rate::HOUR)],
47 | [2/60/60, new Rate(2, Rate::HOUR)],
48 | [1/60, new Rate(1, Rate::MINUTE)],
49 | [2/60, new Rate(2, Rate::MINUTE)],
50 | [1, new Rate(1, Rate::SECOND)],
51 | [2, new Rate(2, Rate::SECOND)],
52 | [1000, new Rate(1, Rate::MILLISECOND)],
53 | [2000, new Rate(2, Rate::MILLISECOND)],
54 | [1000000, new Rate(1, Rate::MICROSECOND)],
55 | [2000000, new Rate(2, Rate::MICROSECOND)],
56 | ];
57 | }
58 |
59 | /**
60 | * Tests building a rate with an invalid unit fails.
61 | *
62 | * @test
63 | * @expectedException InvalidArgumentException
64 | */
65 | public function testInvalidUnit()
66 | {
67 | new Rate(1, "invalid");
68 | }
69 |
70 | /**
71 | * Tests building a rate with an invalid amount fails.
72 | *
73 | * @test
74 | * @expectedException InvalidArgumentException
75 | * @dataProvider provideTestInvalidAmount
76 | */
77 | public function testInvalidAmount($amount)
78 | {
79 | new Rate($amount, Rate::SECOND);
80 | }
81 |
82 | /**
83 | * Provides tests cases for testInvalidAmount().
84 | *
85 | * @return array Test cases.
86 | */
87 | public function provideTestInvalidAmount()
88 | {
89 | return [
90 | [0],
91 | [-1],
92 | ];
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/classes/storage/MemcacheStorage.php:
--------------------------------------------------------------------------------
1 |
14 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
15 | * @license WTFPL
16 | * @deprecated 1.0.0 There's no support for the memcache extension under PHP-7.
17 | * Consider using ext-mecached and {@link MemcachedStorage}.
18 | * This storage will not be removed, however there's no guarantee
19 | * that it will work. As soon as ext-memcache is available for PHP-7
20 | * the deprecation will be reverted.
21 | */
22 | final class MemcacheStorage implements Storage, GlobalScope
23 | {
24 |
25 | /**
26 | * @var \Memcache The connected memcache API.
27 | */
28 | private $memcache;
29 |
30 | /**
31 | * @var string The key for the token bucket.
32 | */
33 | private $key;
34 |
35 | /**
36 | * @var MemcacheMutex The mutex for this storage.
37 | */
38 | private $mutex;
39 |
40 | /**
41 | * @internal
42 | */
43 | const PREFIX = "TokenBucket_";
44 |
45 | /**
46 | * Sets the connected memcache API and the token bucket name.
47 | *
48 | * The api needs to be connected already. I.e. Memcache::connect() was
49 | * already called.
50 | *
51 | * @param string $name The name of the shared token bucket.
52 | * @param \Memcache $memcache The connected memcache API.
53 | */
54 | public function __construct($name, \Memcache $memcache)
55 | {
56 | trigger_error("MemcacheStorage has been deprecated in favour of MemcachedStorage.", E_USER_DEPRECATED);
57 |
58 | $this->memcache = $memcache;
59 | $this->key = self::PREFIX . $name;
60 | $this->mutex = new MemcacheMutex($name, $memcache);
61 | }
62 |
63 | public function bootstrap($microtime)
64 | {
65 | $this->setMicrotime($microtime);
66 | }
67 |
68 | public function isBootstrapped()
69 | {
70 | return $this->memcache->get($this->key) !== false;
71 | }
72 |
73 | public function remove()
74 | {
75 | if (!$this->memcache->delete($this->key)) {
76 | throw new StorageException("Could not remove microtime.");
77 | }
78 | }
79 |
80 | public function setMicrotime($microtime)
81 | {
82 | if (!$this->memcache->set($this->key, $microtime, 0, 0)) {
83 | throw new StorageException("Could not set microtime.");
84 | }
85 | }
86 |
87 | public function getMicrotime()
88 | {
89 | $microtime = $this->memcache->get($this->key);
90 | if ($microtime === false) {
91 | throw new StorageException("The key '$this->key' was not found.");
92 | }
93 | return (double) $microtime;
94 | }
95 |
96 | public function getMutex()
97 | {
98 | return $this->mutex;
99 | }
100 |
101 | public function letMicrotimeUnchanged()
102 | {
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/classes/storage/PredisStorage.php:
--------------------------------------------------------------------------------
1 |
18 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
19 | * @license WTFPL
20 | */
21 | final class PredisStorage implements Storage, GlobalScope
22 | {
23 |
24 | /**
25 | * @var Mutex The mutex.
26 | */
27 | private $mutex;
28 |
29 | /**
30 | * @var Client The redis API.
31 | */
32 | private $redis;
33 |
34 | /**
35 | * @var string The key.
36 | */
37 | private $key;
38 |
39 | /**
40 | * Sets the Redis API.
41 | *
42 | * @param string $name The resource name.
43 | * @param Client $redis The Redis API.
44 | */
45 | public function __construct($name, Client $redis)
46 | {
47 | $this->key = $name;
48 | $this->redis = $redis;
49 | $this->mutex = new PredisMutex([$redis], $name);
50 | }
51 |
52 | public function bootstrap($microtime)
53 | {
54 | $this->setMicrotime($microtime);
55 | }
56 |
57 | public function isBootstrapped()
58 | {
59 | try {
60 | return (bool) $this->redis->exists($this->key);
61 | } catch (PredisException $e) {
62 | throw new StorageException("Failed to check for key existence", 0, $e);
63 | }
64 | }
65 |
66 | public function remove()
67 | {
68 | try {
69 | if (!$this->redis->del($this->key)) {
70 | throw new StorageException("Failed to delete key");
71 | }
72 | } catch (PredisException $e) {
73 | throw new StorageException("Failed to delete key", 0, $e);
74 | }
75 | }
76 |
77 | /**
78 | * @SuppressWarnings(PHPMD)
79 | */
80 | public function setMicrotime($microtime)
81 | {
82 | try {
83 | $data = DoublePacker::pack($microtime);
84 | if (!$this->redis->set($this->key, $data)) {
85 | throw new StorageException("Failed to store microtime");
86 | }
87 | } catch (PredisException $e) {
88 | throw new StorageException("Failed to store microtime", 0, $e);
89 | }
90 | }
91 |
92 | /**
93 | * @SuppressWarnings(PHPMD)
94 | */
95 | public function getMicrotime()
96 | {
97 | try {
98 | $data = $this->redis->get($this->key);
99 | if ($data === false) {
100 | throw new StorageException("Failed to get microtime");
101 | }
102 | return DoublePacker::unpack($data);
103 | } catch (PredisException $e) {
104 | throw new StorageException("Failed to get microtime", 0, $e);
105 | }
106 | }
107 |
108 | public function getMutex()
109 | {
110 | return $this->mutex;
111 | }
112 |
113 | public function letMicrotimeUnchanged()
114 | {
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/classes/storage/PHPRedisStorage.php:
--------------------------------------------------------------------------------
1 |
20 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
21 | * @license WTFPL
22 | */
23 | final class PHPRedisStorage implements Storage, GlobalScope
24 | {
25 |
26 | /**
27 | * @var Mutex The mutex.
28 | */
29 | private $mutex;
30 |
31 | /**
32 | * @var Redis The redis API.
33 | */
34 | private $redis;
35 |
36 | /**
37 | * @var string The key.
38 | */
39 | private $key;
40 |
41 | /**
42 | * Sets the connected Redis API.
43 | *
44 | * The Redis API needs to be connected yet. I.e. Redis::connect() was
45 | * called already.
46 | *
47 | * @param string $name The resource name.
48 | * @param Redis $redis The Redis API.
49 | */
50 | public function __construct($name, Redis $redis)
51 | {
52 | $this->key = $name;
53 | $this->redis = $redis;
54 | $this->mutex = new PHPRedisMutex([$redis], $name);
55 | }
56 |
57 | public function bootstrap($microtime)
58 | {
59 | $this->setMicrotime($microtime);
60 | }
61 |
62 | public function isBootstrapped()
63 | {
64 | try {
65 | return $this->redis->exists($this->key);
66 | } catch (RedisException $e) {
67 | throw new StorageException("Failed to check for key existence", 0, $e);
68 | }
69 | }
70 |
71 | public function remove()
72 | {
73 | try {
74 | if (!$this->redis->del($this->key)) {
75 | throw new StorageException("Failed to delete key");
76 | }
77 | } catch (RedisException $e) {
78 | throw new StorageException("Failed to delete key", 0, $e);
79 | }
80 | }
81 |
82 | /**
83 | * @SuppressWarnings(PHPMD)
84 | */
85 | public function setMicrotime($microtime)
86 | {
87 | try {
88 | $data = DoublePacker::pack($microtime);
89 |
90 | if (!$this->redis->set($this->key, $data)) {
91 | throw new StorageException("Failed to store microtime");
92 | }
93 | } catch (RedisException $e) {
94 | throw new StorageException("Failed to store microtime", 0, $e);
95 | }
96 | }
97 |
98 | /**
99 | * @SuppressWarnings(PHPMD)
100 | */
101 | public function getMicrotime()
102 | {
103 | try {
104 | $data = $this->redis->get($this->key);
105 | if ($data === false) {
106 | throw new StorageException("Failed to get microtime");
107 | }
108 | return DoublePacker::unpack($data);
109 | } catch (RedisException $e) {
110 | throw new StorageException("Failed to get microtime", 0, $e);
111 | }
112 | }
113 |
114 | public function getMutex()
115 | {
116 | return $this->mutex;
117 | }
118 |
119 | public function letMicrotimeUnchanged()
120 | {
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/classes/BlockingConsumer.php:
--------------------------------------------------------------------------------
1 |
11 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
12 | * @license WTFPL
13 | */
14 | final class BlockingConsumer
15 | {
16 |
17 | /**
18 | * @var TokenBucket The token bucket.
19 | */
20 | private $bucket;
21 |
22 | /**
23 | * @var int|null optional timeout in seconds.
24 | */
25 | private $timeout;
26 |
27 | /**
28 | * Set the token bucket and an optional timeout.
29 | *
30 | * @param TokenBucket $bucket The token bucket.
31 | * @param int|null $timeout Optional timeout in seconds.
32 | */
33 | public function __construct(TokenBucket $bucket, $timeout = null)
34 | {
35 | $this->bucket = $bucket;
36 |
37 | if ($timeout < 0) {
38 | throw new \InvalidArgumentException("Timeout must be null or positive");
39 | }
40 | $this->timeout = $timeout;
41 | }
42 |
43 | /**
44 | * Consumes tokens.
45 | *
46 | * If the underlying token bucket doesn't have sufficient tokens, the
47 | * consumer blocks until it can consume the tokens.
48 | *
49 | * @param int $tokens The token amount.
50 | *
51 | * @throws \LengthException The token amount is larger than the bucket's capacity.
52 | * @throws StorageException The stored microtime could not be accessed.
53 | * @throws TimeoutException The timeout was exceeded.
54 | */
55 | public function consume($tokens)
56 | {
57 | $timedOut = is_null($this->timeout) ? null : (microtime(true) + $this->timeout);
58 | while (!$this->bucket->consume($tokens, $seconds)) {
59 | self::throwTimeoutIfExceeded($timedOut);
60 | $seconds = self::keepSecondsWithinTimeout($seconds, $timedOut);
61 |
62 | // avoid an overflow before converting $seconds into microseconds.
63 | if ($seconds > 1) {
64 | // leave more than one second to avoid sleeping the minimum of one millisecond.
65 | $sleepSeconds = ((int) $seconds) - 1;
66 |
67 | sleep($sleepSeconds);
68 | $seconds -= $sleepSeconds;
69 | }
70 |
71 | // sleep at least 1 millisecond.
72 | usleep(max(1000, $seconds * 1000000));
73 | }
74 | }
75 |
76 | /**
77 | * Checks if the timeout was exceeded.
78 | *
79 | * @param float|null $timedOut Timestamp when to time out.
80 | * @throws TimeoutException The timeout was exceeded.
81 | */
82 | private static function throwTimeoutIfExceeded($timedOut)
83 | {
84 | if (is_null($timedOut)) {
85 | return;
86 | }
87 | if (time() >= $timedOut) {
88 | throw new TimeoutException("Timed out");
89 | }
90 | }
91 |
92 | /**
93 | * Adjusts the wait seconds to be within the timeout.
94 | *
95 | * @param float $seconds Seconds to wait for the next consume try.
96 | * @param float|null $timedOut Timestamp when to time out.
97 | *
98 | * @return float Seconds for waiting
99 | */
100 | private static function keepSecondsWithinTimeout($seconds, $timedOut)
101 | {
102 | if (is_null($timedOut)) {
103 | return $seconds;
104 | }
105 | $remainingSeconds = max($timedOut - microtime(true), 0);
106 | return min($remainingSeconds, $seconds);
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/tests/storage/PHPRedisStorageTest.php:
--------------------------------------------------------------------------------
1 |
13 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
14 | * @license WTFPL
15 | * @see PHPRedisStorage
16 | */
17 | class PHPRedisStorageTest extends \PHPUnit_Framework_TestCase
18 | {
19 |
20 | /**
21 | * @var Redis The API.
22 | */
23 | private $redis;
24 |
25 | /**
26 | * @var PHPRedisStorage The SUT.
27 | */
28 | private $storage;
29 |
30 | protected function setUp()
31 | {
32 | parent::setUp();
33 |
34 | if (!getenv("REDIS_URI")) {
35 | $this->markTestSkipped();
36 | }
37 | $uri = parse_url(getenv("REDIS_URI"));
38 | $this->redis = new Redis();
39 | $this->redis->connect($uri["host"]);
40 |
41 | $this->storage = new PHPRedisStorage("test", $this->redis);
42 | }
43 |
44 | /**
45 | * Tests broken server communication.
46 | *
47 | * @param callable $method The tested method.
48 | * @test
49 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
50 | * @dataProvider provideTestBrokenCommunication
51 | */
52 | public function testBrokenCommunication(callable $method)
53 | {
54 | $this->redis->close();
55 | call_user_func($method, $this->storage);
56 | }
57 |
58 | /**
59 | * Provides test cases for testBrokenCommunication().
60 | *
61 | * @return array Testcases.
62 | */
63 | public function provideTestBrokenCommunication()
64 | {
65 | return [
66 | [function (PHPRedisStorage $storage) {
67 | $storage->bootstrap(1);
68 | }],
69 | [function (PHPRedisStorage $storage) {
70 | $storage->isBootstrapped();
71 | }],
72 | [function (PHPRedisStorage $storage) {
73 | $storage->remove();
74 | }],
75 | [function (PHPRedisStorage $storage) {
76 | $storage->setMicrotime(1);
77 | }],
78 | [function (PHPRedisStorage $storage) {
79 | $storage->getMicrotime();
80 | }],
81 | ];
82 | }
83 |
84 | /**
85 | * Tests remove() fails.
86 | *
87 | * @test
88 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
89 | */
90 | public function testRemoveFails()
91 | {
92 | $this->storage->bootstrap(1);
93 | $this->storage->remove();
94 |
95 | $this->storage->remove();
96 | }
97 |
98 | /**
99 | * Tests setMicrotime() fails.
100 | *
101 | * @test
102 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
103 | */
104 | public function testSetMicrotimeFails()
105 | {
106 | $redis = $this->createMock(Redis::class);
107 | $redis->expects($this->once())->method("set")
108 | ->willReturn(false);
109 | $storage = new PHPRedisStorage("test", $redis);
110 | $storage->setMicrotime(1);
111 | }
112 |
113 | /**
114 | * Tests getMicrotime() fails.
115 | *
116 | * @test
117 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
118 | */
119 | public function testGetMicrotimeFails()
120 | {
121 | $this->storage->bootstrap(1);
122 | $this->storage->remove();
123 |
124 | $this->storage->getMicrotime();
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/tests/storage/PredisStorageTest.php:
--------------------------------------------------------------------------------
1 |
14 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
15 | * @license WTFPL
16 | * @see PredisStorage
17 | */
18 | class PredisStorageTest extends \PHPUnit_Framework_TestCase
19 | {
20 |
21 | /**
22 | * @var Client The API.
23 | */
24 | private $redis;
25 |
26 | /**
27 | * @var PredisStorage The SUT.
28 | */
29 | private $storage;
30 |
31 | protected function setUp()
32 | {
33 | parent::setUp();
34 |
35 | if (!getenv("REDIS_URI")) {
36 | $this->markTestSkipped();
37 | }
38 | $this->redis = new Client(getenv("REDIS_URI"));
39 | $this->storage = new PredisStorage("test", $this->redis);
40 | }
41 |
42 | /**
43 | * Tests broken server communication.
44 | *
45 | * @param callable $method The tested method.
46 | * @test
47 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
48 | * @dataProvider provideTestBrokenCommunication
49 | */
50 | public function testBrokenCommunication(callable $method)
51 | {
52 | $redis = $this->createMock(Client::class);
53 | $redis->expects($this->once())->method("__call")
54 | ->willThrowException(new ClientException());
55 | $storage = new PredisStorage("test", $redis);
56 | call_user_func($method, $storage);
57 | }
58 |
59 | /**
60 | * Provides test cases for testBrokenCommunication().
61 | *
62 | * @return array Testcases.
63 | */
64 | public function provideTestBrokenCommunication()
65 | {
66 | return [
67 | [function (PredisStorage $storage) {
68 | $storage->bootstrap(1);
69 | }],
70 | [function (PredisStorage $storage) {
71 | $storage->isBootstrapped();
72 | }],
73 | [function (PredisStorage $storage) {
74 | $storage->remove();
75 | }],
76 | [function (PredisStorage $storage) {
77 | $storage->setMicrotime(1);
78 | }],
79 | [function (PredisStorage $storage) {
80 | $storage->getMicrotime();
81 | }],
82 | ];
83 | }
84 |
85 | /**
86 | * Tests remove() fails.
87 | *
88 | * @test
89 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
90 | */
91 | public function testRemoveFails()
92 | {
93 | $this->storage->bootstrap(1);
94 | $this->storage->remove();
95 |
96 | $this->storage->remove();
97 | }
98 |
99 | /**
100 | * Tests setMicrotime() fails.
101 | *
102 | * @test
103 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
104 | */
105 | public function testSetMicrotimeFails()
106 | {
107 | $redis = $this->createMock(Client::class);
108 | $redis->expects($this->once())->method("__call")
109 | ->with("set")
110 | ->willReturn(false);
111 | $storage = new PredisStorage("test", $redis);
112 | $storage->setMicrotime(1);
113 | }
114 |
115 | /**
116 | * Tests getMicrotime() fails.
117 | *
118 | * @test
119 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
120 | */
121 | public function testGetMicrotimeFails()
122 | {
123 | $this->storage->bootstrap(1);
124 | $this->storage->remove();
125 |
126 | $this->storage->getMicrotime();
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/classes/storage/IPCStorage.php:
--------------------------------------------------------------------------------
1 |
16 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
17 | * @license WTFPL
18 | */
19 | final class IPCStorage implements Storage, GlobalScope
20 | {
21 |
22 | /**
23 | * @var Mutex The mutex.
24 | */
25 | private $mutex;
26 |
27 | /**
28 | * @var int $key The System V IPC key.
29 | */
30 | private $key;
31 |
32 | /**
33 | * @var resource The shared memory.
34 | */
35 | private $memory;
36 |
37 | /**
38 | * @var resource The semaphore id.
39 | */
40 | private $semaphore;
41 |
42 | /**
43 | * Sets the System V IPC key for the shared memory and its semaphore.
44 | *
45 | * You can create the key with PHP's function ftok().
46 | *
47 | * @param int $key The System V IPC key.
48 | *
49 | * @throws StorageException Could initialize IPC infrastructure.
50 | */
51 | public function __construct($key)
52 | {
53 | $this->key = $key;
54 | $this->attach();
55 | }
56 |
57 | /**
58 | * Attaches the shared memory segment.
59 | *
60 | * @throws StorageException Could not initialize IPC infrastructure.
61 | */
62 | private function attach()
63 | {
64 | try {
65 | $this->semaphore = sem_get($this->key);
66 | $this->mutex = new SemaphoreMutex($this->semaphore);
67 | } catch (\InvalidArgumentException $e) {
68 | throw new StorageException("Could not get semaphore id.", 0, $e);
69 | }
70 |
71 | $this->memory = shm_attach($this->key, 128);
72 | if (!is_resource($this->memory)) {
73 | throw new StorageException("Failed to attach to shared memory.");
74 | }
75 | }
76 |
77 | public function bootstrap($microtime)
78 | {
79 | if (is_null($this->memory)) {
80 | $this->attach();
81 | }
82 | $this->setMicrotime($microtime);
83 | }
84 |
85 | public function isBootstrapped()
86 | {
87 | return !is_null($this->memory) && shm_has_var($this->memory, 0);
88 | }
89 |
90 | public function remove()
91 | {
92 | if (!shm_remove($this->memory)) {
93 | throw new StorageException("Could not release shared memory.");
94 | }
95 | $this->memory = null;
96 |
97 | if (!sem_remove($this->semaphore)) {
98 | throw new StorageException("Could not remove semaphore.");
99 | }
100 | $this->semaphore = null;
101 | }
102 |
103 | /**
104 | * @SuppressWarnings(PHPMD)
105 | */
106 | public function setMicrotime($microtime)
107 | {
108 | $data = DoublePacker::pack($microtime);
109 | if (!shm_put_var($this->memory, 0, $data)) {
110 | throw new StorageException("Could not store in shared memory.");
111 | }
112 | }
113 |
114 | /**
115 | * @SuppressWarnings(PHPMD)
116 | */
117 | public function getMicrotime()
118 | {
119 | $data = shm_get_var($this->memory, 0);
120 | if ($data === false) {
121 | throw new StorageException("Could not read from shared memory.");
122 | }
123 | return DoublePacker::unpack($data);
124 | }
125 |
126 | public function getMutex()
127 | {
128 | return $this->mutex;
129 | }
130 |
131 | public function letMicrotimeUnchanged()
132 | {
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/tests/util/TokenConverterTest.php:
--------------------------------------------------------------------------------
1 |
12 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
13 | * @license WTFPL
14 | * @see TokenConverter
15 | */
16 | class TokenConverterTest extends \PHPUnit_Framework_TestCase
17 | {
18 |
19 | use PHPMock;
20 |
21 | /**
22 | * Tests convertSecondsToTokens().
23 | *
24 | * @param int $expected The expected tokens.
25 | * @param double $seconds The seconds.
26 | * @param Rate $rate The rate.
27 | *
28 | * @test
29 | * @dataProvider provideTestConvertSecondsToTokens
30 | */
31 | public function testConvertSecondsToTokens($expected, $seconds, Rate $rate)
32 | {
33 | $converter = new TokenConverter($rate);
34 | $this->assertEquals($expected, $converter->convertSecondsToTokens($seconds));
35 | }
36 |
37 | /**
38 | * Provides test cases for testConvertSecondsToTokens().
39 | *
40 | * @return array Test cases.
41 | */
42 | public function provideTestConvertSecondsToTokens()
43 | {
44 | return [
45 | [0, 0.9, new Rate(1, Rate::SECOND)],
46 | [1, 1, new Rate(1, Rate::SECOND)],
47 | [1, 1.1, new Rate(1, Rate::SECOND)],
48 |
49 | [1000, 1, new Rate(1, Rate::MILLISECOND)],
50 | [2000, 2, new Rate(1, Rate::MILLISECOND)],
51 |
52 | [0, 59, new Rate(1, Rate::MINUTE)],
53 | [1, 60, new Rate(1, Rate::MINUTE)],
54 | [1, 61, new Rate(1, Rate::MINUTE)],
55 | ];
56 | }
57 |
58 | /**
59 | * Tests convertTokensToSeconds().
60 | *
61 | * @param double $expected The expected seconds.
62 | * @param int $tokens The tokens.
63 | * @param Rate $rate The rate.
64 | *
65 | * @test
66 | * @dataProvider provideTestconvertTokensToSeconds
67 | */
68 | public function testconvertTokensToSeconds($expected, $tokens, Rate $rate)
69 | {
70 | $converter = new TokenConverter($rate);
71 | $this->assertEquals($expected, $converter->convertTokensToSeconds($tokens));
72 | }
73 |
74 | /**
75 | * Provides test cases for testconvertTokensToSeconds().
76 | *
77 | * @return array Test cases.
78 | */
79 | public function provideTestconvertTokensToSeconds()
80 | {
81 | return [
82 | [0.001, 1, new Rate(1, Rate::MILLISECOND)],
83 | [0.002, 2, new Rate(1, Rate::MILLISECOND)],
84 | [1, 1, new Rate(1, Rate::SECOND)],
85 | [2, 2, new Rate(1, Rate::SECOND)],
86 | ];
87 | }
88 |
89 | /**
90 | * Tests convertTokensToMicrotime().
91 | *
92 | * @param double $delta The expected delta.
93 | * @param int $tokens The tokens.
94 | * @param Rate $rate The rate.
95 | *
96 | * @test
97 | * @dataProvider provideTestConvertTokensToMicrotime
98 | */
99 | public function testConvertTokensToMicrotime($delta, $tokens, Rate $rate)
100 | {
101 | $microtime = $this->getFunctionMock(__NAMESPACE__, "microtime");
102 | $microtime->expects($this->any())->willReturn(100000);
103 |
104 | $converter = new TokenConverter($rate);
105 |
106 | $this->assertEquals(microtime(true) + $delta, $converter->convertTokensToMicrotime($tokens));
107 | }
108 |
109 | /**
110 | * Provides test cases for testConvertTokensToMicrotime().
111 | *
112 | * @return array Test cases.
113 | */
114 | public function provideTestConvertTokensToMicrotime()
115 | {
116 | return [
117 | [-1, 1, new Rate(1, Rate::SECOND)],
118 | [-2, 2, new Rate(1, Rate::SECOND)],
119 | [-0.001, 1, new Rate(1, Rate::MILLISECOND)],
120 | ];
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/tests/storage/FileStorageTest.php:
--------------------------------------------------------------------------------
1 |
13 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
14 | * @license WTFPL
15 | * @see FileStorage
16 | */
17 | class FileStorageTest extends \PHPUnit_Framework_TestCase
18 | {
19 |
20 | use PHPMock;
21 |
22 | /**
23 | * Tests opening the file fails.
24 | *
25 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
26 | */
27 | public function testOpeningFails()
28 | {
29 | vfsStream::setup('test');
30 | @new FileStorage(vfsStream::url("test/nonexisting/test"));
31 | }
32 |
33 | /**
34 | * Tests seeking fails in setMicrotime().
35 | *
36 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
37 | */
38 | public function testSetMicrotimeFailsSeeking()
39 | {
40 | $this->getFunctionMock(__NAMESPACE__, "fseek")
41 | ->expects($this->atLeastOnce())
42 | ->willReturn(-1);
43 |
44 | vfsStream::setup('test');
45 | $storage = new FileStorage(vfsStream::url("test/data"));
46 | $storage->setMicrotime(1.1234);
47 | }
48 |
49 | /**
50 | * Tests writings fails in setMicrotime().
51 | *
52 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
53 | */
54 | public function testSetMicrotimeFailsWriting()
55 | {
56 | $this->getFunctionMock(__NAMESPACE__, "fwrite")
57 | ->expects($this->atLeastOnce())
58 | ->willReturn(false);
59 |
60 | vfsStream::setup('test');
61 | $storage = new FileStorage(vfsStream::url("test/data"));
62 | $storage->setMicrotime(1.1234);
63 | }
64 |
65 | /**
66 | * Tests seeking fails in getMicrotime().
67 | *
68 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
69 | */
70 | public function testGetMicrotimeFailsSeeking()
71 | {
72 | $this->getFunctionMock(__NAMESPACE__, "fseek")
73 | ->expects($this->atLeastOnce())
74 | ->willReturn(-1);
75 |
76 | vfsStream::setup('test');
77 | $storage = new FileStorage(vfsStream::url("test/data"));
78 | $storage->getMicrotime();
79 | }
80 |
81 | /**
82 | * Tests reading fails in getMicrotime().
83 | *
84 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
85 | */
86 | public function testGetMicrotimeFailsReading()
87 | {
88 | $this->getFunctionMock(__NAMESPACE__, "fread")
89 | ->expects($this->atLeastOnce())
90 | ->willReturn(false);
91 |
92 | vfsStream::setup('test');
93 | $storage = new FileStorage(vfsStream::url("test/data"));
94 | $storage->getMicrotime();
95 | }
96 |
97 | /**
98 | * Tests readinging too little in getMicrotime().
99 | *
100 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
101 | */
102 | public function testGetMicrotimeReadsToLittle()
103 | {
104 | $data = new vfsStreamFile("data");
105 | $data->setContent("1234567");
106 | vfsStream::setup('test')->addChild($data);
107 |
108 | $storage = new FileStorage(vfsStream::url("test/data"));
109 | $storage->getMicrotime();
110 | }
111 |
112 | /**
113 | * Tests deleting fails.
114 | *
115 | * @test
116 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
117 | */
118 | public function testRemoveFails()
119 | {
120 | $data = new vfsStreamFile("data");
121 | $root = vfsStream::setup('test');
122 | $root->chmod(0);
123 | $root->addChild($data);
124 |
125 | $storage = new FileStorage(vfsStream::url("test/data"));
126 | $storage->remove();
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/classes/storage/FileStorage.php:
--------------------------------------------------------------------------------
1 |
16 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
17 | * @license WTFPL
18 | */
19 | final class FileStorage implements Storage, GlobalScope
20 | {
21 |
22 | /**
23 | * @var Mutex The mutex.
24 | */
25 | private $mutex;
26 |
27 | /**
28 | * @var resource The file handle.
29 | */
30 | private $fileHandle;
31 |
32 | /**
33 | * @var string The file path.
34 | */
35 | private $path;
36 |
37 | /**
38 | * Sets the file path and opens it.
39 | *
40 | * If the file does not exist yet, it will be created. This is an atomic
41 | * operation.
42 | *
43 | * @param string $path The file path.
44 | * @throws StorageException Failed to open the file.
45 | */
46 | public function __construct($path)
47 | {
48 | $this->path = $path;
49 | $this->open();
50 | }
51 |
52 | /**
53 | * Opens the file and initializes the mutex.
54 | *
55 | * @throws StorageException Failed to open the file.
56 | */
57 | private function open()
58 | {
59 | $this->fileHandle = fopen($this->path, "c+");
60 | if (!is_resource($this->fileHandle)) {
61 | throw new StorageException("Could not open '$this->path'.");
62 | }
63 | $this->mutex = new FlockMutex($this->fileHandle);
64 | }
65 |
66 | /**
67 | * Closes the file handle.
68 | *
69 | * @internal
70 | */
71 | public function __destruct()
72 | {
73 | fclose($this->fileHandle);
74 | }
75 |
76 | public function isBootstrapped()
77 | {
78 | $stats = fstat($this->fileHandle);
79 | return $stats["size"] > 0;
80 | }
81 |
82 | public function bootstrap($microtime)
83 | {
84 | $this->open(); // remove() could have deleted the file.
85 | $this->setMicrotime($microtime);
86 | }
87 |
88 | public function remove()
89 | {
90 | // Truncate to notify isBootstrapped() about the new state.
91 | if (!ftruncate($this->fileHandle, 0)) {
92 | throw new StorageException("Could not truncate $this->path");
93 | }
94 | if (!unlink($this->path)) {
95 | throw new StorageException("Could not delete $this->path");
96 | }
97 | }
98 |
99 | /**
100 | * @SuppressWarnings(PHPMD)
101 | */
102 | public function setMicrotime($microtime)
103 | {
104 | if (fseek($this->fileHandle, 0) !== 0) {
105 | throw new StorageException("Could not move to beginning of the file.");
106 | }
107 |
108 | $data = DoublePacker::pack($microtime);
109 | $result = fwrite($this->fileHandle, $data, strlen($data));
110 | if ($result !== strlen($data)) {
111 | throw new StorageException("Could not write to storage.");
112 | }
113 | }
114 |
115 | /**
116 | * @SuppressWarnings(PHPMD)
117 | */
118 | public function getMicrotime()
119 | {
120 | if (fseek($this->fileHandle, 0) !== 0) {
121 | throw new StorageException("Could not move to beginning of the file.");
122 | }
123 | $data = fread($this->fileHandle, 8);
124 | if ($data === false) {
125 | throw new StorageException("Could not read from storage.");
126 | }
127 |
128 | return DoublePacker::unpack($data);
129 | }
130 |
131 | public function getMutex()
132 | {
133 | return $this->mutex;
134 | }
135 |
136 | public function letMicrotimeUnchanged()
137 | {
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/tests/storage/MemcachedStorageTest.php:
--------------------------------------------------------------------------------
1 |
11 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
12 | * @license WTFPL
13 | * @see MemcachedStorage
14 | */
15 | class MemcachedStorageTest extends \PHPUnit_Framework_TestCase
16 | {
17 |
18 | /**
19 | * @var \Memcached The memcached API.
20 | */
21 | private $memcached;
22 |
23 | /**
24 | * @var MemcachedStorage The SUT.
25 | */
26 | private $storage;
27 |
28 | protected function setUp()
29 | {
30 | parent::setUp();
31 |
32 | if (!getenv("MEMCACHE_HOST")) {
33 | $this->markTestSkipped();
34 | return;
35 | }
36 | $this->memcached = new \Memcached();
37 | $this->memcached->addServer(getenv("MEMCACHE_HOST"), 11211);
38 |
39 | $this->storage = new MemcachedStorage("test", $this->memcached);
40 | $this->storage->bootstrap(123);
41 | }
42 |
43 | protected function tearDown()
44 | {
45 | parent::tearDown();
46 |
47 | if (!getenv("MEMCACHE_HOST")) {
48 | return;
49 | }
50 | $memcached = new \Memcached();
51 | $memcached->addServer(getenv("MEMCACHE_HOST"), 11211);
52 | $memcached->flush();
53 | }
54 |
55 | /**
56 | * Tests bootstrap() returns silenty if the key exists already.
57 | *
58 | * @test
59 | */
60 | public function testBootstrapReturnsSilentlyIfKeyExists()
61 | {
62 | $this->storage->bootstrap(234);
63 | }
64 |
65 | /**
66 | * Tests bootstrap() fails.
67 | *
68 | * @test
69 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
70 | */
71 | public function testBootstrapFails()
72 | {
73 | $storage = new MemcachedStorage("test", new \Memcached());
74 | $storage->bootstrap(123);
75 | }
76 |
77 | /**
78 | * Tests isBootstrapped() fails
79 | *
80 | * @test
81 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
82 | */
83 | public function testIsBootstrappedFails()
84 | {
85 | $this->markTestIncomplete();
86 | }
87 |
88 | /**
89 | * Tests remove() fails
90 | *
91 | * @test
92 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
93 | */
94 | public function testRemoveFails()
95 | {
96 | $storage = new MemcachedStorage("test", new \Memcached());
97 | $storage->remove();
98 | }
99 |
100 | /**
101 | * Tests setMicrotime() fails if getMicrotime() wasn't called first.
102 | *
103 | * @test
104 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
105 | */
106 | public function testSetMicrotimeFailsIfGetMicrotimeNotCalledFirst()
107 | {
108 | $this->storage->setMicrotime(123);
109 | }
110 |
111 | /**
112 | * Tests setMicrotime() fails.
113 | *
114 | * @test
115 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
116 | */
117 | public function testSetMicrotimeFails()
118 | {
119 | $this->storage->getMicrotime();
120 | $this->memcached->resetServerList();
121 | $this->storage->setMicrotime(123);
122 | }
123 |
124 | /**
125 | * Tests setMicrotime() returns silenty if the cas operation failed.
126 | *
127 | * @test
128 | */
129 | public function testSetMicrotimeReturnsSilentlyIfCASFailed()
130 | {
131 | // acquire cas token
132 | $this->storage->getMicrotime();
133 |
134 | // invalidate the cas token
135 | $storage2 = new MemcachedStorage("test", $this->memcached);
136 | $storage2->getMicrotime();
137 | $storage2->setMicrotime(234);
138 |
139 | $this->storage->setMicrotime(123);
140 | }
141 |
142 |
143 | /**
144 | * Tests getMicrotime() fails.
145 | *
146 | * @test
147 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
148 | */
149 | public function testGetMicrotimeFails()
150 | {
151 | $storage = new MemcachedStorage("test", new \Memcached());
152 | $storage->getMicrotime();
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/classes/storage/MemcachedStorage.php:
--------------------------------------------------------------------------------
1 |
14 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
15 | * @license WTFPL
16 | */
17 | final class MemcachedStorage implements Storage, GlobalScope
18 | {
19 |
20 | /**
21 | * @var \Memcached The memcached API.
22 | */
23 | private $memcached;
24 |
25 | /**
26 | * @var float The CAS token.
27 | */
28 | private $casToken;
29 |
30 | /**
31 | * @var string The key for the token bucket.
32 | */
33 | private $key;
34 |
35 | /**
36 | * @var CASMutex The mutex for this storage.
37 | */
38 | private $mutex;
39 |
40 | /**
41 | * @internal
42 | */
43 | const PREFIX = "TokenBucketD_";
44 |
45 | /**
46 | * Sets the memcached API and the token bucket name.
47 | *
48 | * The api needs to have at least one server in its pool. I.e.
49 | * it has to be added with Memcached::addServer().
50 | *
51 | * @param string $name The name of the shared token bucket.
52 | * @param \Memcached $memcached The memcached API.
53 | */
54 | public function __construct($name, \Memcached $memcached)
55 | {
56 | $this->key = self::PREFIX . $name;
57 | $this->mutex = new CASMutex();
58 | $this->memcached = $memcached;
59 | }
60 |
61 | public function bootstrap($microtime)
62 | {
63 | if ($this->memcached->add($this->key, $microtime)) {
64 | $this->mutex->notify(); // [CAS] Stop TokenBucket::bootstrap()
65 | return;
66 | }
67 | if ($this->memcached->getResultCode() === \Memcached::RES_NOTSTORED) {
68 | // [CAS] repeat TokenBucket::bootstrap()
69 | return;
70 | }
71 | throw new StorageException($this->memcached->getResultMessage(), $this->memcached->getResultCode());
72 | }
73 |
74 | public function isBootstrapped()
75 | {
76 | if ($this->memcached->get($this->key) !== false) {
77 | $this->mutex->notify(); // [CAS] Stop TokenBucket::bootstrap()
78 | return true;
79 | }
80 | if ($this->memcached->getResultCode() === \Memcached::RES_NOTFOUND) {
81 | return false;
82 | }
83 | throw new StorageException($this->memcached->getResultMessage(), $this->memcached->getResultCode());
84 | }
85 |
86 | public function remove()
87 | {
88 | if (!$this->memcached->delete($this->key)) {
89 | throw new StorageException($this->memcached->getResultMessage(), $this->memcached->getResultCode());
90 | }
91 | }
92 |
93 | public function setMicrotime($microtime)
94 | {
95 | if (is_null($this->casToken)) {
96 | throw new StorageException("CAS token is null. Call getMicrotime() first.");
97 | }
98 | if ($this->memcached->cas($this->casToken, $this->key, $microtime)) {
99 | $this->mutex->notify(); // [CAS] Stop TokenBucket::consume()
100 | return;
101 | }
102 | if ($this->memcached->getResultCode() === \Memcached::RES_DATA_EXISTS) {
103 | // [CAS] repeat TokenBucket::consume()
104 | return;
105 | }
106 | throw new StorageException($this->memcached->getResultMessage(), $this->memcached->getResultCode());
107 | }
108 |
109 | public function getMicrotime()
110 | {
111 | $getDelayed = $this->memcached->getDelayed([$this->key], true);
112 | if (!$getDelayed) {
113 | throw new StorageException($this->memcached->getResultMessage(), $this->memcached->getResultCode());
114 | }
115 |
116 | $result = $this->memcached->fetchAll();
117 | if (!$result) {
118 | throw new StorageException($this->memcached->getResultMessage(), $this->memcached->getResultCode());
119 | }
120 |
121 | $microtime = $result[0]["value"];
122 | $this->casToken = $result[0]["cas"];
123 | if ($this->casToken === null) {
124 | throw new StorageException("Failed to aquire a CAS token.");
125 | }
126 |
127 | return (double) $microtime;
128 | }
129 |
130 | public function getMutex()
131 | {
132 | return $this->mutex;
133 | }
134 |
135 | public function letMicrotimeUnchanged()
136 | {
137 | $this->mutex->notify();
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/tests/storage/SharedStorageTest.php:
--------------------------------------------------------------------------------
1 |
21 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
22 | * @license WTFPL
23 | * @see Storage
24 | */
25 | class SharedStorageTest extends \PHPUnit_Framework_TestCase
26 | {
27 |
28 | /**
29 | * @var Storages Tests storages.
30 | */
31 | private $storages = [];
32 |
33 | protected function tearDown()
34 | {
35 | foreach ($this->storages as $storage) {
36 | try {
37 | @$storage->remove();
38 | } catch (StorageException $e) {
39 | // ignore missing vfsStream files.
40 | }
41 | }
42 | }
43 |
44 | /**
45 | * Provides shared Storage implementations.
46 | *
47 | * @return callable[][] Storage factories.
48 | */
49 | public function provideStorageFactories()
50 | {
51 | $cases = [
52 | [function ($name) {
53 | return new SessionStorage($name);
54 | }],
55 |
56 | [function ($name) {
57 | vfsStream::setup('fileStorage');
58 | return new FileStorage(vfsStream::url("fileStorage/$name"));
59 | }],
60 |
61 | [function ($name) {
62 | $key = ftok(__FILE__, $name);
63 | return new IPCStorage($key);
64 | }],
65 |
66 | [function ($name) {
67 | $pdo = new \PDO("sqlite::memory:");
68 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
69 | return new PDOStorage($name, $pdo);
70 | }],
71 | ];
72 |
73 | if (getenv("MYSQL_DSN")) {
74 | $cases[] = [function ($name) {
75 | $pdo = new \PDO(getenv("MYSQL_DSN"), getenv("MYSQL_USER"));
76 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
77 | $pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, false);
78 |
79 | $storage = new PDOStorage($name, $pdo);
80 |
81 | $pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, true);
82 |
83 | return $storage;
84 | }];
85 | }
86 | if (getenv("PGSQL_DSN")) {
87 | $cases[] = [function ($name) {
88 | $pdo = new \PDO(getenv("PGSQL_DSN"), getenv("PGSQL_USER"));
89 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
90 | return new PDOStorage($name, $pdo);
91 | }];
92 | }
93 | if (getenv("MEMCACHE_HOST")) {
94 | $cases[] = [function ($name) {
95 | $memcached = new \Memcached();
96 | $memcached->addServer(getenv("MEMCACHE_HOST"), 11211);
97 | return new MemcachedStorage($name, $memcached);
98 | }];
99 | }
100 | if (getenv("REDIS_URI")) {
101 | $cases["PHPRedisStorage"] = [function ($name) {
102 | $uri = parse_url(getenv("REDIS_URI"));
103 | $redis = new Redis();
104 | $redis->connect($uri["host"]);
105 | return new PHPRedisStorage($name, $redis);
106 | }];
107 |
108 | $cases["PredisStorage"] = [function ($name) {
109 | $redis = new Client(getenv("REDIS_URI"));
110 | return new PredisStorage($name, $redis);
111 | }];
112 | }
113 | return $cases;
114 | }
115 |
116 | /**
117 | * Tests two storages with different names don't interfere each other.
118 | *
119 | * @param callable $factory The storage factory.
120 | *
121 | * @dataProvider provideStorageFactories
122 | * @test
123 | */
124 | public function testStoragesDontInterfere(callable $factory)
125 | {
126 | $storageA = call_user_func($factory, "A");
127 | $storageA->bootstrap(0);
128 | $storageA->getMicrotime();
129 | $this->storages[] = $storageA;
130 |
131 | $storageB = call_user_func($factory, "B");
132 | $storageB->bootstrap(0);
133 | $storageB->getMicrotime();
134 | $this->storages[] = $storageB;
135 |
136 | $storageA->setMicrotime(1);
137 | $storageB->setMicrotime(2);
138 |
139 | $this->assertNotEquals($storageA->getMicrotime(), $storageB->getMicrotime());
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/tests/BlockingConsumerTest.php:
--------------------------------------------------------------------------------
1 |
13 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
14 | * @license WTFPL
15 | * @see BlockingConsumer
16 | */
17 | class BlockingConsumerTest extends \PHPUnit_Framework_TestCase
18 | {
19 |
20 | /**
21 | * @var MockEnvironment Mock for microtime() and usleep().
22 | */
23 | private $sleepEnvironent;
24 |
25 | protected function setUp()
26 | {
27 | $builder = new SleepEnvironmentBuilder();
28 | $builder->addNamespace(__NAMESPACE__)
29 | ->addNamespace("bandwidthThrottle\\tokenBucket\\util")
30 | ->setTimestamp(1417011228);
31 |
32 | $this->sleepEnvironent = $builder->build();
33 | $this->sleepEnvironent->enable();
34 | }
35 |
36 | protected function tearDown()
37 | {
38 | $this->sleepEnvironent->disable();
39 | }
40 |
41 | /**
42 | * Tests comsumption of cumulated tokens.
43 | *
44 | * @test
45 | */
46 | public function testConsecutiveConsume()
47 | {
48 | $rate = new Rate(1, Rate::SECOND);
49 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
50 | $consumer = new BlockingConsumer($bucket);
51 | $bucket->bootstrap(10);
52 | $time = microtime(true);
53 |
54 | $consumer->consume(1);
55 | $consumer->consume(2);
56 | $consumer->consume(3);
57 | $consumer->consume(4);
58 | $this->assertEquals(microtime(true) - $time, 0);
59 |
60 | $consumer->consume(1);
61 | $this->assertEquals(microtime(true) - $time, 1);
62 |
63 | sleep(3);
64 | $time = microtime(true);
65 | $consumer->consume(4);
66 | $this->assertEquals(microtime(true) - $time, 1);
67 | }
68 |
69 | /**
70 | * Tests consume().
71 | *
72 | * @param double $expected The expected duration.
73 | * @param int $tokens The tokens to consume.
74 | * @param Rate $rate The rate.
75 | *
76 | * @dataProvider provideTestConsume
77 | * @test
78 | */
79 | public function testConsume($expected, $tokens, Rate $rate)
80 | {
81 | $bucket = new TokenBucket(10000, $rate, new SingleProcessStorage());
82 | $consumer = new BlockingConsumer($bucket);
83 | $bucket->bootstrap();
84 |
85 | $time = microtime(true);
86 | $consumer->consume($tokens);
87 | $this->assertEquals($expected, microtime(true) - $time);
88 | }
89 |
90 | /**
91 | * Returns test cases for testConsume().
92 | *
93 | * @return array Test cases.
94 | */
95 | public function provideTestConsume()
96 | {
97 | return [
98 | [0.5, 500, new Rate(1, Rate::MILLISECOND)],
99 | [1, 1000, new Rate(1, Rate::MILLISECOND)],
100 | [1.5, 1500, new Rate(1, Rate::MILLISECOND)],
101 | [2, 2000, new Rate(1, Rate::MILLISECOND)],
102 | [2.5, 2500, new Rate(1, Rate::MILLISECOND)],
103 | ];
104 | }
105 |
106 | /**
107 | * Tests consume() won't sleep less than one millisecond.
108 | *
109 | * @test
110 | */
111 | public function testMinimumSleep()
112 | {
113 | $rate = new Rate(10, Rate::MILLISECOND);
114 | $bucket = new TokenBucket(1, $rate, new SingleProcessStorage());
115 | $bucket->bootstrap();
116 |
117 | $consumer = new BlockingConsumer($bucket);
118 | $time = microtime(true);
119 |
120 | $consumer->consume(1);
121 | $this->assertLessThan(1e-5, abs((microtime(true) - $time) - 0.001));
122 | }
123 |
124 | /**
125 | * consume() should fail after a timeout.
126 | *
127 | * @expectedException \bandwidthThrottle\tokenBucket\TimeoutException
128 | * @test
129 | */
130 | public function consumeShouldFailAfterTimeout()
131 | {
132 | $rate = new Rate(0.1, Rate::SECOND);
133 | $bucket = new TokenBucket(1, $rate, new SingleProcessStorage());
134 | $bucket->bootstrap(0);
135 | $consumer = new BlockingConsumer($bucket, 9);
136 |
137 | $consumer->consume(1);
138 | }
139 |
140 | /**
141 | * consume() should not fail before a timeout.
142 | *
143 | * @test
144 | */
145 | public function consumeShouldNotFailBeforeTimeout()
146 | {
147 | $rate = new Rate(0.1, Rate::SECOND);
148 | $bucket = new TokenBucket(1, $rate, new SingleProcessStorage());
149 | $bucket->bootstrap(0);
150 | $consumer = new BlockingConsumer($bucket, 11);
151 |
152 | $consumer->consume(1);
153 | }
154 |
155 | /**
156 | * consume() should not never time out.
157 | *
158 | * @test
159 | */
160 | public function consumeWithoutTimeoutShouldNeverFail()
161 | {
162 | $rate = new Rate(0.1, Rate::YEAR);
163 | $bucket = new TokenBucket(1, $rate, new SingleProcessStorage());
164 | $bucket->bootstrap(0);
165 | $consumer = new BlockingConsumer($bucket);
166 |
167 | $consumer->consume(1);
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/tests/storage/PDOStorageTest.php:
--------------------------------------------------------------------------------
1 |
15 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
16 | * @license WTFPL
17 | * @see PDOStorage
18 | */
19 | class PDOStorageTest extends \PHPUnit_Framework_TestCase
20 | {
21 |
22 | /**
23 | * @var Storage[] The tested storages;
24 | */
25 | private $storages = [];
26 |
27 | protected function tearDown()
28 | {
29 | foreach ($this->storages as $storage) {
30 | $storage->remove();
31 | }
32 | }
33 |
34 | /**
35 | * Provides the PDO.
36 | *
37 | * @return PDO[][] The PDOs.
38 | */
39 | public function providePDO()
40 | {
41 | $cases = [
42 | [new \PDO("sqlite::memory:")],
43 | ];
44 | if (getenv("MYSQL_DSN")) {
45 | $pdo = new \PDO(getenv("MYSQL_DSN"), getenv("MYSQL_USER"));
46 | $pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, false);
47 | $cases[] = [$pdo];
48 | }
49 | if (getenv("PGSQL_DSN")) {
50 | $pdo = new \PDO(getenv("PGSQL_DSN"), getenv("PGSQL_USER"));
51 | $cases[] = [$pdo];
52 | }
53 | foreach ($cases as $case) {
54 | $case[0]->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
55 | }
56 | return $cases;
57 | }
58 |
59 | /**
60 | * Tests instantiation with a too long name should fail.
61 | *
62 | * @test
63 | * @expectedException \LengthException
64 | */
65 | public function testTooLongNameFails()
66 | {
67 | $pdo = new \PDO("sqlite::memory:");
68 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
69 | new PDOStorage(str_repeat(" ", 129), $pdo);
70 | }
71 |
72 | /**
73 | * Tests instantiation with a long name should not fail.
74 | *
75 | * @test
76 | */
77 | public function testLongName()
78 | {
79 | $pdo = new \PDO("sqlite::memory:");
80 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
81 | new PDOStorage(str_repeat(" ", 128), $pdo);
82 | }
83 |
84 | /**
85 | * Tests instantiation with PDO in wrong error mode should fail.
86 | *
87 | * @param int $errorMode The invalid error mode.
88 | * @test
89 | * @expectedException \InvalidArgumentException
90 | * @dataProvider provideTestInvalidErrorMode
91 | */
92 | public function testInvalidErrorMode($errorMode)
93 | {
94 | $pdo = new \PDO("sqlite::memory:");
95 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, $errorMode);
96 | new PDOStorage("test", $pdo);
97 | }
98 |
99 | /**
100 | * Provides test cases for testInvalidErrorMode()
101 | *
102 | * @return int[][] Invalid error modes.
103 | */
104 | public function provideTestInvalidErrorMode()
105 | {
106 | return [
107 | [\PDO::ERRMODE_SILENT],
108 | [\PDO::ERRMODE_WARNING],
109 | ];
110 | }
111 |
112 | /**
113 | * Tests instantiation with PDO in valid error mode.
114 | *
115 | * @test
116 | */
117 | public function testValidErrorMode()
118 | {
119 | $pdo = new \PDO("sqlite::memory:");
120 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
121 | new PDOStorage("test", $pdo);
122 | }
123 |
124 | /**
125 | * Tests bootstrap() adds a row to an existing table.
126 | *
127 | * @param \PDO $pdo The PDO.
128 | * @dataProvider providePDO
129 | * @test
130 | */
131 | public function testBootstrapAddsRow(\PDO $pdo)
132 | {
133 | $storageA = new PDOStorage("A", $pdo);
134 | $storageA->bootstrap(1);
135 | $this->storages[] = $storageA;
136 |
137 | $storageB = new PDOStorage("B", $pdo);
138 | $storageB->bootstrap(2);
139 | $this->storages[] = $storageB;
140 |
141 | $this->assertEquals(1, $storageA->getMicrotime());
142 | $this->assertEquals(2, $storageB->getMicrotime());
143 | }
144 |
145 | /**
146 | * Tests bootstrap() would add a row to an existing table, but fails.
147 | *
148 | * @param \PDO $pdo The PDO.
149 | * @dataProvider providePDO
150 | * @test
151 | * @expectedException bandwidthThrottle\tokenBucket\storage\StorageException
152 | */
153 | public function testBootstrapFailsForExistingRow(\PDO $pdo)
154 | {
155 | $storageA = new PDOStorage("A", $pdo);
156 | $storageA->bootstrap(0);
157 | $this->storages[] = $storageA;
158 |
159 | $storageA2 = new PDOStorage("A", $pdo);
160 | $storageA2->bootstrap(0);
161 | }
162 |
163 | /**
164 | * Tests remove() removes only one row.
165 | *
166 | * @param \PDO $pdo The PDO.
167 | * @dataProvider providePDO
168 | * @test
169 | */
170 | public function testRemoveOneRow(\PDO $pdo)
171 | {
172 | $storageA = new PDOStorage("A", $pdo);
173 | $storageA->bootstrap(0);
174 | $this->storages[] = $storageA;
175 |
176 | $storageB = new PDOStorage("B", $pdo);
177 | $storageB->bootstrap(0);
178 | $storageB->remove();
179 |
180 | $this->assertTrue($storageA->isBootstrapped());
181 | $this->assertFalse($storageB->isBootstrapped());
182 | }
183 |
184 | /**
185 | * Tests remove() removes the table after the last row.
186 | *
187 | * @param \PDO $pdo The PDO.
188 | * @dataProvider providePDO
189 | * @test
190 | */
191 | public function testRemoveTable(\PDO $pdo)
192 | {
193 | $storage = new PDOStorage("test", $pdo);
194 | $storage->bootstrap(0);
195 | $storage->remove();
196 | $this->assertFalse($storage->isBootstrapped());
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Token Bucket
2 |
3 | This is a threadsafe implementation of the [Token Bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket)
4 | in PHP. You can use a token bucket to limit an usage rate for a resource
5 | (e.g. a stream bandwidth or an API usage).
6 |
7 | The token bucket is an abstract metaphor which doesn't have a direction of
8 | the resource consumption. I.e. you can limit a rate for consuming or producing.
9 | E.g. you can limit the consumption rate of a third party API service, or you
10 | can limit the usage rate of your own API service.
11 |
12 | # Installation
13 |
14 | Use [Composer](https://getcomposer.org/):
15 |
16 | ```sh
17 | composer require bandwidth-throttle/token-bucket
18 | ```
19 |
20 | # Usage
21 |
22 | The package is in the namespace
23 | [`bandwidthThrottle\tokenBucket`](http://bandwidth-throttle.github.io/token-bucket/api/namespace-bandwidthThrottle.tokenBucket.html).
24 |
25 | ## Example
26 |
27 | This example will limit the rate of a global resource to 10 requests per second
28 | for all requests.
29 |
30 | ```php
31 | use bandwidthThrottle\tokenBucket\Rate;
32 | use bandwidthThrottle\tokenBucket\TokenBucket;
33 | use bandwidthThrottle\tokenBucket\storage\FileStorage;
34 |
35 | $storage = new FileStorage(__DIR__ . "/api.bucket");
36 | $rate = new Rate(10, Rate::SECOND);
37 | $bucket = new TokenBucket(10, $rate, $storage);
38 | $bucket->bootstrap(10);
39 |
40 | if (!$bucket->consume(1, $seconds)) {
41 | http_response_code(429);
42 | header(sprintf("Retry-After: %d", floor($seconds)));
43 | exit();
44 | }
45 |
46 | echo "API response";
47 | ```
48 |
49 | Note: In this example `TokenBucket::bootstrap()` is part of the code. This is
50 | not recommended for production, as this is producing unnecessary storage
51 | communication. `TokenBucket::bootstrap()` should be part of the application's
52 | bootstrap or deploy process.
53 |
54 | ## Scope of the storage
55 |
56 | First you need to decide the scope of your resource. I.e. do you want to limit
57 | it per request, per user or amongst all requests? You can do this by choosing a
58 | [`Storage`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.storage.Storage.html)
59 | implementation of the desired scope:
60 |
61 | - The [`RequestScope`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.storage.scope.RequestScope.html)
62 | limits the rate only within one request. E.g. to limit the bandwidth of a download.
63 | Each requests will have the same bandwidth limit.
64 |
65 | - The [`SessionScope`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.storage.scope.SessionScope.html)
66 | limits the rate of a resource within a session. The rate is controlled over
67 | all requests of one session. E.g. to limit the API usage per user.
68 |
69 | - The [`GlobalScope`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.storage.scope.GlobalScope.html)
70 | limits the rate of a resource for all processes (i.e. requests). E.g. to limit
71 | the aggregated download bandwidth of a resource over all processes. This scope
72 | permits race conditions between processes. The TokenBucket is therefore
73 | synchronized on a shared mutex.
74 |
75 | ## TokenBucket
76 |
77 | When you have your storage you can finally instantiate a
78 | [`TokenBucket`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.TokenBucket.html).
79 | The first parameter is the capacity of the bucket. I.e. there will be never
80 | more tokens available. This also means that consuming more tokens than the
81 | capacity is invalid.
82 |
83 | The second parameter is the token-add-[`Rate`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.Rate.html).
84 | It determines the speed for filling the bucket with tokens. The rate is the
85 | amount of tokens added per unit, e.g. `new Rate(100, Rate::SECOND)`
86 | would add 100 tokens per second.
87 |
88 | The third parameter is the storage, which is used to persist the token amount
89 | of the bucket. The storage does determine the scope of the bucket.
90 |
91 | ### Bootstrapping
92 |
93 | A token bucket needs to be bootstrapped. While the method
94 | [`TokenBucket::bootstrap()`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.TokenBucket.html#_bootstrap)
95 | doesn't have any side effects on an already bootstrapped bucket, it is not
96 | recommended do call it for every request. Better include that in your
97 | application's bootstrap or deploy process.
98 |
99 | ### Consuming
100 |
101 | Now that you have a bootstrapped bucket, you can start consuming tokens. The
102 | method [`TokenBucket::consume()`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.TokenBucket.html#_consume)
103 | will either return `true` if the tokens were consumed or `false` else.
104 | If the tokens were consumed your application can continue to serve the resource.
105 |
106 | Else if the tokens were not consumed you should not serve the resource.
107 | In that case `consume()` did write a duration of seconds into its second parameter
108 | (which was passed by reference). This is the duration until sufficient
109 | tokens would be available.
110 |
111 | ## BlockingConsumer
112 |
113 | In the first example we did either serve the request or fail with the HTTP status
114 | code 429. This is actually a very resource efficient way of throtteling API
115 | requests as it doesn't reserve resources on your server.
116 |
117 | However sometimes
118 | it is desirable not to fail but instead wait a little bit and then continue
119 | serving the requests. You can do this by consuming the token bucket with
120 | a [`BlockingConsumer`](http://bandwidth-throttle.github.io/token-bucket/api/class-bandwidthThrottle.tokenBucket.BlockingConsumer.html).
121 |
122 | ```php
123 | use bandwidthThrottle\tokenBucket\Rate;
124 | use bandwidthThrottle\tokenBucket\TokenBucket;
125 | use bandwidthThrottle\tokenBucket\BlockingConsumer;
126 | use bandwidthThrottle\tokenBucket\storage\FileStorage;
127 |
128 | $storage = new FileStorage(__DIR__ . "/api.bucket");
129 | $rate = new Rate(10, Rate::SECOND);
130 | $bucket = new TokenBucket(10, $rate, $storage);
131 | $consumer = new BlockingConsumer($bucket);
132 | $bucket->bootstrap(10);
133 |
134 | // This will block until one token is available.
135 | $consumer->consume(1);
136 |
137 | echo "API response";
138 | ```
139 |
140 | This will effectively limit the rate to 10 requests per seconds as well. But
141 | in this case the client has not to bother with the 429 error. Instead the
142 | connection is just delayed to the desired rate.
143 |
144 | # License and authors
145 |
146 | This project is free and under the WTFPL.
147 | Responsible for this project is Markus Malkusch markus@malkusch.de.
148 |
149 | ## Donations
150 |
151 | If you like this project and feel generous donate a few Bitcoins here:
152 | [1335STSwu9hST4vcMRppEPgENMHD2r1REK](bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK)
153 |
154 | [](https://travis-ci.org/bandwidth-throttle/token-bucket)
155 |
--------------------------------------------------------------------------------
/classes/storage/PDOStorage.php:
--------------------------------------------------------------------------------
1 |
15 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
16 | * @license WTFPL
17 | */
18 | final class PDOStorage implements Storage, GlobalScope
19 | {
20 |
21 | /**
22 | * @var PDO The pdo.
23 | */
24 | private $pdo;
25 |
26 | /**
27 | * @var string The shared name of the token bucket.
28 | */
29 | private $name;
30 |
31 | /**
32 | * @var TransactionalMutex The mutex.
33 | */
34 | private $mutex;
35 |
36 | /**
37 | * Sets the PDO and the bucket's name for the shared storage.
38 | *
39 | * The name should be the same for all token buckets which share the same
40 | * token storage.
41 | *
42 | * The transaction isolation level should avoid lost updates, i.e. it should
43 | * be at least Repeatable Read.
44 | *
45 | * @param string $name The name of the token bucket.
46 | * @param PDO $pdo The PDO.
47 | *
48 | * @throws \LengthException The id should not be longer than 128 characters.
49 | * @throws \InvalidArgumentException PDO must be configured to throw exceptions.
50 | */
51 | public function __construct($name, \PDO $pdo)
52 | {
53 | if (strlen($name) > 128) {
54 | throw new \LengthException("The name should not be longer than 128 characters.");
55 | }
56 | if ($pdo->getAttribute(\PDO::ATTR_ERRMODE) !== \PDO::ERRMODE_EXCEPTION) {
57 | throw new \InvalidArgumentException("The pdo must have PDO::ERRMODE_EXCEPTION set.");
58 | }
59 | $this->pdo = $pdo;
60 | $this->name = $name;
61 | $this->mutex = new TransactionalMutex($pdo);
62 | }
63 |
64 | public function bootstrap($microtime)
65 | {
66 | try {
67 | try {
68 | $this->onErrorRollback(function () {
69 | $options = $this->forVendor(["mysql" => "ENGINE=InnoDB CHARSET=utf8"]);
70 | $this->pdo->exec(
71 | "CREATE TABLE TokenBucket (
72 | name VARCHAR(128) PRIMARY KEY,
73 | microtime DOUBLE PRECISION NOT NULL
74 | ) $options;"
75 | );
76 | });
77 | } catch (\PDOException $e) {
78 | /*
79 | * This exception is ignored to provide a portable way
80 | * to create a table only if it doesn't exist yet.
81 | */
82 | }
83 |
84 | $insert = $this->pdo->prepare(
85 | "INSERT INTO TokenBucket (name, microtime) VALUES (?, ?)"
86 | );
87 | $insert->execute([$this->name, $microtime]);
88 | if ($insert->rowCount() !== 1) {
89 | throw new StorageException("Failed to insert token bucket into storage '$this->name'");
90 | }
91 | } catch (\PDOException $e) {
92 | throw new StorageException("Failed to bootstrap storage '$this->name'", 0, $e);
93 | }
94 | }
95 |
96 | public function isBootstrapped()
97 | {
98 | try {
99 | return $this->onErrorRollback(function () {
100 | return (bool) $this->querySingleValue(
101 | "SELECT 1 FROM TokenBucket WHERE name=?",
102 | [$this->name]
103 | );
104 | });
105 | } catch (StorageException $e) {
106 | // This seems to be a portable way to determine if the table exists or not.
107 | return false;
108 | } catch (\PDOException $e) {
109 | throw new StorageException("Can't check bootstrapped state", 0, $e);
110 | }
111 | }
112 |
113 | public function remove()
114 | {
115 | try {
116 | $delete = $this->pdo->prepare("DELETE FROM TokenBucket WHERE name = ?");
117 | $delete->execute([$this->name]);
118 |
119 | $count = $this->querySingleValue("SELECT count(*) FROM TokenBucket");
120 | if ($count == 0) {
121 | $this->pdo->exec("DROP TABLE TokenBucket");
122 | }
123 | } catch (\PDOException $e) {
124 | throw new StorageException("Failed to remove the storage.", 0, $e);
125 | }
126 | }
127 |
128 | public function setMicrotime($microtime)
129 | {
130 | try {
131 | $update = $this->pdo->prepare(
132 | "UPDATE TokenBucket SET microtime = ? WHERE name = ?"
133 | );
134 | $update->execute([$microtime, $this->name]);
135 | } catch (\PDOException $e) {
136 | throw new StorageException("Failed to write to storage '$this->name'.", 0, $e);
137 | }
138 | }
139 |
140 | public function getMicrotime()
141 | {
142 | $forUpdate = $this->forVendor(["sqlite" => ""], "FOR UPDATE");
143 | return (double) $this->querySingleValue(
144 | "SELECT microtime from TokenBucket WHERE name = ? $forUpdate",
145 | [$this->name]
146 | );
147 | }
148 |
149 | /**
150 | * Returns a vendor specific dialect value.
151 | *
152 | * @param string[] $map The vendor dialect map.
153 | * @param string $default The default value, which is empty per default.
154 | *
155 | * @return string The vendor specific value.
156 | */
157 | private function forVendor(array $map, $default = "")
158 | {
159 | $vendor = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
160 | return isset($map[$vendor]) ? $map[$vendor] : $default;
161 | }
162 |
163 | /**
164 | * Returns one value from a query.
165 | *
166 | * @param string $sql The SQL query.
167 | * @param array $parameters The optional query parameters.
168 | *
169 | * @return string The value.
170 | * @throws StorageException The query failed.
171 | */
172 | private function querySingleValue($sql, $parameters = [])
173 | {
174 | try {
175 | $statement = $this->pdo->prepare($sql);
176 | $statement->execute($parameters);
177 |
178 | $value = $statement->fetchColumn();
179 |
180 | $statement->closeCursor();
181 | if ($value === false) {
182 | throw new StorageException("The query returned no result.");
183 | }
184 | return $value;
185 | } catch (\PDOException $e) {
186 | throw new StorageException("The query failed.", 0, $e);
187 | }
188 | }
189 |
190 | /**
191 | * Rollback to an implicit savepoint.
192 | *
193 | * @throws \PDOException
194 | */
195 | private function onErrorRollback(callable $code)
196 | {
197 | if (!$this->pdo->inTransaction()) {
198 | return call_user_func($code);
199 | }
200 |
201 | $this->pdo->exec("SAVEPOINT onErrorRollback");
202 | try {
203 | $result = call_user_func($code);
204 | } catch (\Exception $e) {
205 | $this->pdo->exec("ROLLBACK TO SAVEPOINT onErrorRollback");
206 | throw $e;
207 | }
208 | $this->pdo->exec("RELEASE SAVEPOINT onErrorRollback");
209 | return $result;
210 | }
211 |
212 | public function getMutex()
213 | {
214 | return $this->mutex;
215 | }
216 |
217 | public function letMicrotimeUnchanged()
218 | {
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/tests/storage/StorageTest.php:
--------------------------------------------------------------------------------
1 |
23 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
24 | * @license WTFPL
25 | * @see Storage
26 | */
27 | class StorageTest extends \PHPUnit_Framework_TestCase
28 | {
29 |
30 | /**
31 | * @var Storage The tested storage;
32 | */
33 | private $storage;
34 |
35 | protected function tearDown()
36 | {
37 | if (!is_null($this->storage) && $this->storage->isBootstrapped()) {
38 | $this->storage->remove();
39 | }
40 | }
41 |
42 | /**
43 | * Provides uninitialized Storage implementations.
44 | *
45 | * @return callable[][] Storage factories.
46 | */
47 | public function provideStorageFactories()
48 | {
49 | $cases = [
50 | "SingleProcessStorage" => [function () {
51 | return new SingleProcessStorage();
52 | }],
53 | "SessionStorage" => [function () {
54 | return new SessionStorage("test");
55 | }],
56 | "FileStorage" => [function () {
57 | vfsStream::setup('fileStorage');
58 | return new FileStorage(vfsStream::url("fileStorage/data"));
59 | }],
60 | "sqlite" => [function () {
61 | $pdo = new \PDO("sqlite::memory:");
62 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
63 | return new PDOStorage("test", $pdo);
64 | }],
65 | "IPCStorage" => [function () {
66 | return new IPCStorage(ftok(__FILE__, "a"));
67 | }],
68 | ];
69 |
70 | if (getenv("MYSQL_DSN")) {
71 | $cases["MYSQL"] = [function () {
72 | $pdo = new \PDO(getenv("MYSQL_DSN"), getenv("MYSQL_USER"));
73 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
74 | $pdo->setAttribute(\PDO::ATTR_AUTOCOMMIT, false);
75 | return new PDOStorage("test", $pdo);
76 | }];
77 | }
78 | if (getenv("PGSQL_DSN")) {
79 | $cases["PGSQL"] = [function () {
80 | $pdo = new \PDO(getenv("PGSQL_DSN"), getenv("PGSQL_USER"));
81 | $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
82 | return new PDOStorage("test", $pdo);
83 | }];
84 | }
85 | if (getenv("MEMCACHE_HOST")) {
86 | $cases["MemcachedStorage"] = [function () {
87 | $memcached = new \Memcached();
88 | $memcached->addServer(getenv("MEMCACHE_HOST"), 11211);
89 | return new MemcachedStorage("test", $memcached);
90 | }];
91 | }
92 | if (getenv("REDIS_URI")) {
93 | $cases["PHPRedisStorage"] = [function () {
94 | $uri = parse_url(getenv("REDIS_URI"));
95 | $redis = new Redis();
96 | $redis->connect($uri["host"]);
97 | return new PHPRedisStorage("test", $redis);
98 | }];
99 |
100 | $cases["PredisStorage"] = [function () {
101 | $redis = new Client(getenv("REDIS_URI"));
102 | return new PredisStorage("test", $redis);
103 | }];
104 | }
105 | return $cases;
106 | }
107 |
108 | /**
109 | * Tests setMicrotime() and getMicrotime().
110 | *
111 | * @param callable $storageFactory Returns a storage.
112 | * @test
113 | * @dataProvider provideStorageFactories
114 | */
115 | public function testSetAndGetMicrotime(callable $storageFactory)
116 | {
117 | $this->storage = call_user_func($storageFactory);
118 | $this->storage->bootstrap(1);
119 | $this->storage->getMicrotime();
120 |
121 | $this->storage->setMicrotime(1.1);
122 | $this->assertSame(1.1, $this->storage->getMicrotime());
123 | $this->assertSame(1.1, $this->storage->getMicrotime());
124 |
125 | $this->storage->setMicrotime(1.2);
126 | $this->assertSame(1.2, $this->storage->getMicrotime());
127 |
128 | $this->storage->setMicrotime(1436551945.0192);
129 | $this->assertSame(1436551945.0192, $this->storage->getMicrotime());
130 | }
131 |
132 | /**
133 | * Tests isBootstrapped().
134 | *
135 | * @param callable $storageFactory Returns a storage.
136 | * @test
137 | * @dataProvider provideStorageFactories
138 | */
139 | public function testBootstrap(callable $storageFactory)
140 | {
141 | $this->storage = call_user_func($storageFactory);
142 |
143 | $this->storage->bootstrap(123);
144 | $this->assertTrue($this->storage->isBootstrapped());
145 | $this->assertEquals(123, $this->storage->getMicrotime());
146 | }
147 |
148 | /**
149 | * Tests isBootstrapped().
150 | *
151 | * @param callable $storageFactory Returns a storage.
152 | * @test
153 | * @dataProvider provideStorageFactories
154 | */
155 | public function testIsBootstrapped(callable $storageFactory)
156 | {
157 | $this->storage = call_user_func($storageFactory);
158 | $this->assertFalse($this->storage->isBootstrapped());
159 |
160 | $this->storage->bootstrap(123);
161 | $this->assertTrue($this->storage->isBootstrapped());
162 |
163 | $this->storage->remove();
164 | $this->assertFalse($this->storage->isBootstrapped());
165 | }
166 |
167 | /**
168 | * Tests remove().
169 | *
170 | * @param callable $storageFactory Returns a storage.
171 | * @test
172 | * @dataProvider provideStorageFactories
173 | */
174 | public function testRemove(callable $storageFactory)
175 | {
176 | $this->storage = call_user_func($storageFactory);
177 | $this->storage->bootstrap(123);
178 |
179 | $this->storage->remove();
180 | $this->assertFalse($this->storage->isBootstrapped());
181 | }
182 |
183 | /**
184 | * When no tokens are available, the bucket should return false.
185 | *
186 | * @param callable $storageFactory Returns a storage.
187 | * @test
188 | * @dataProvider provideStorageFactories
189 | */
190 | public function testConsumingUnavailableTokensReturnsFalse(callable $storageFactory)
191 | {
192 | $this->storage = call_user_func($storageFactory);
193 | $capacity = 10;
194 | $rate = new Rate(1, Rate::SECOND);
195 | $bucket = new TokenBucket($capacity, $rate, $this->storage);
196 | $bucket->bootstrap(0);
197 |
198 | $this->assertFalse($bucket->consume(10));
199 | }
200 |
201 | /**
202 | * When tokens are available, the bucket should return true.
203 | *
204 | * @param callable $storageFactory Returns a storage.
205 | * @test
206 | * @dataProvider provideStorageFactories
207 | */
208 | public function testConsumingAvailableTokensReturnsTrue(callable $storageFactory)
209 | {
210 | $this->storage = call_user_func($storageFactory);
211 | $capacity = 10;
212 | $rate = new Rate(1, Rate::SECOND);
213 | $bucket = new TokenBucket($capacity, $rate, $this->storage);
214 | $bucket->bootstrap(10);
215 |
216 | $this->assertTrue($bucket->consume(10));
217 | }
218 |
219 | /**
220 | * Tests synchronized bootstrap
221 | *
222 | * @param callable $storageFactory Returns a storage.
223 | * @test
224 | * @dataProvider provideStorageFactories
225 | */
226 | public function testSynchronizedBootstrap(callable $storageFactory)
227 | {
228 | $this->storage = call_user_func($storageFactory);
229 | $this->storage->getMutex()->synchronized(function () {
230 | $this->assertFalse($this->storage->isBootstrapped());
231 | $this->storage->bootstrap(123);
232 | $this->assertTrue($this->storage->isBootstrapped());
233 | });
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/classes/TokenBucket.php:
--------------------------------------------------------------------------------
1 |
19 | * use bandwidthThrottle\tokenBucket\Rate;
20 | * use bandwidthThrottle\tokenBucket\TokenBucket;
21 | * use bandwidthThrottle\tokenBucket\storage\FileStorage;
22 | *
23 | * $storage = new FileStorage(__DIR__ . "/api.bucket");
24 | * $rate = new Rate(10, Rate::SECOND);
25 | * $bucket = new TokenBucket(10, $rate, $storage);
26 | * $bucket->bootstrap(10);
27 | *
28 | * if (!$bucket->consume(1, $seconds)) {
29 | * http_response_code(429);
30 | * header(sprintf("Retry-After: %d", floor($seconds)));
31 | * exit();
32 | * }
33 | *
34 | *
35 | * @author Markus Malkusch
36 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
37 | * @license WTFPL
38 | */
39 | final class TokenBucket
40 | {
41 |
42 | /**
43 | * @var Rate The rate.
44 | */
45 | private $rate;
46 |
47 | /**
48 | * @var int Token capacity of this bucket.
49 | */
50 | private $capacity;
51 |
52 | /**
53 | * @var Storage The storage.
54 | */
55 | private $storage;
56 |
57 | /**
58 | * @var TokenConverter Token converter.
59 | */
60 | private $tokenConverter;
61 |
62 | /**
63 | * Initializes the Token bucket.
64 | *
65 | * The storage determines the scope of the bucket.
66 | *
67 | * @param int $capacity positive capacity of the bucket
68 | * @param Rate $rate rate
69 | * @param Storage $storage storage
70 | */
71 | public function __construct($capacity, Rate $rate, Storage $storage)
72 | {
73 | if ($capacity <= 0) {
74 | throw new \InvalidArgumentException("Capacity should be greater than 0.");
75 | }
76 |
77 | $this->capacity = $capacity;
78 | $this->rate = $rate;
79 | $this->storage = $storage;
80 |
81 | $this->tokenConverter = new TokenConverter($rate);
82 | }
83 |
84 | /**
85 | * Bootstraps the storage with an initial amount of tokens.
86 | *
87 | * If the storage was already bootstrapped this method returns silently.
88 | *
89 | * While you could call bootstrap() on each request, you should not do that!
90 | * This method will do unnecessary storage communications just to see that
91 | * bootstrapping was performed already. You therefore should call that
92 | * method in your application's bootstrap or deploy process.
93 | *
94 | * This method is threadsafe.
95 | *
96 | * @param int $tokens Initial amount of tokens, default is 0.
97 | *
98 | * @throws StorageException Bootstrapping failed.
99 | * @throws \LengthException The initial amount of tokens is larger than the capacity.
100 | */
101 | public function bootstrap($tokens = 0)
102 | {
103 | try {
104 | if ($tokens > $this->capacity) {
105 | throw new \LengthException(
106 | "Initial token amount ($tokens) is larger than the capacity ($this->capacity)."
107 | );
108 | }
109 | if ($tokens < 0) {
110 | throw new \InvalidArgumentException(
111 | "Initial token amount ($tokens) should be greater than 0."
112 | );
113 | }
114 |
115 | $this->storage->getMutex()
116 | ->check(function () {
117 | return !$this->storage->isBootstrapped();
118 | })
119 | ->then(function () use ($tokens) {
120 | $this->storage->bootstrap($this->tokenConverter->convertTokensToMicrotime($tokens));
121 | });
122 | } catch (MutexException $e) {
123 | throw new StorageException("Could not lock bootstrapping", 0, $e);
124 | }
125 | }
126 |
127 | /**
128 | * Consumes tokens from the bucket.
129 | *
130 | * This method consumes only tokens if there are sufficient tokens available.
131 | * If there aren't sufficient tokens, no tokens will be removed and the
132 | * remaining seconds to wait are written to $seconds.
133 | *
134 | * This method is threadsafe.
135 | *
136 | * @param int $tokens The token amount.
137 | * @param double &$seconds The seconds to wait.
138 | *
139 | * @return bool If tokens were consumed.
140 | * @SuppressWarnings(PHPMD)
141 | *
142 | * @throws \LengthException The token amount is larger than the capacity.
143 | * @throws StorageException The stored microtime could not be accessed.
144 | */
145 | public function consume($tokens, &$seconds = 0)
146 | {
147 | try {
148 | if ($tokens > $this->capacity) {
149 | throw new \LengthException("Token amount ($tokens) is larger than the capacity ($this->capacity).");
150 | }
151 | if ($tokens <= 0) {
152 | throw new \InvalidArgumentException("Token amount ($tokens) should be greater than 0.");
153 | }
154 |
155 | return $this->storage->getMutex()->synchronized(
156 | function () use ($tokens, &$seconds) {
157 | $tokensAndMicrotime = $this->loadTokensAndTimestamp();
158 | $microtime = $tokensAndMicrotime["microtime"];
159 | $availableTokens = $tokensAndMicrotime["tokens"];
160 |
161 | $delta = $availableTokens - $tokens;
162 | if ($delta < 0) {
163 | $this->storage->letMicrotimeUnchanged();
164 | $passed = microtime(true) - $microtime;
165 | $seconds = max(0, $this->tokenConverter->convertTokensToSeconds($tokens) - $passed);
166 | return false;
167 | } else {
168 | $microtime += $this->tokenConverter->convertTokensToSeconds($tokens);
169 | $this->storage->setMicrotime($microtime);
170 | $seconds = 0;
171 | return true;
172 | }
173 | }
174 | );
175 | } catch (MutexException $e) {
176 | throw new StorageException("Could not lock token consumption.", 0, $e);
177 | }
178 | }
179 |
180 | /**
181 | * Returns the token add rate.
182 | *
183 | * @return Rate The rate.
184 | */
185 | public function getRate()
186 | {
187 | return $this->rate;
188 | }
189 |
190 | /**
191 | * The token capacity of this bucket.
192 | *
193 | * @return int The capacity.
194 | */
195 | public function getCapacity()
196 | {
197 | return $this->capacity;
198 | }
199 |
200 | /**
201 | * Returns the currently available tokens of this bucket.
202 | *
203 | * This is a purely informative method. Use this method if you are
204 | * interested in the amount of remaining tokens. Those tokens
205 | * could be consumed instantly. This method will not consume any token.
206 | * Use {@link consume()} to do so.
207 | *
208 | * This method will never return more than the capacity of the bucket.
209 | *
210 | * @return int amount of currently available tokens
211 | * @throws StorageException The stored microtime could not be accessed.
212 | */
213 | public function getTokens()
214 | {
215 | return $this->loadTokensAndTimestamp()["tokens"];
216 | }
217 |
218 | /**
219 | * Loads the stored timestamp and its respective amount of tokens.
220 | *
221 | * This method is a convenience method to allow sharing code in
222 | * {@link TokenBucket::getTokens()} and {@link TokenBucket::consume()}
223 | * while accessing the storage only once.
224 | *
225 | * @throws StorageException The stored microtime could not be accessed.
226 | * @return array tokens and microtime
227 | */
228 | private function loadTokensAndTimestamp()
229 | {
230 | $microtime = $this->storage->getMicrotime();
231 |
232 | // Drop overflowing tokens
233 | $minMicrotime = $this->tokenConverter->convertTokensToMicrotime($this->capacity);
234 | if ($minMicrotime > $microtime) {
235 | $microtime = $minMicrotime;
236 | }
237 |
238 | $tokens = $this->tokenConverter->convertMicrotimeToTokens($microtime);
239 | return [
240 | "tokens" => $tokens,
241 | "microtime" => $microtime
242 | ];
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/tests/TokenBucketTest.php:
--------------------------------------------------------------------------------
1 |
15 | * @link bitcoin:1335STSwu9hST4vcMRppEPgENMHD2r1REK Donations
16 | * @license WTFPL
17 | * @see TokenBucket
18 | */
19 | class TokenBucketTest extends \PHPUnit_Framework_TestCase
20 | {
21 |
22 | /**
23 | * @var MockEnvironment Mock for microtime() and usleep().
24 | */
25 | private $sleepEnvironent;
26 |
27 | protected function setUp()
28 | {
29 | $builder = new SleepEnvironmentBuilder();
30 | $builder->addNamespace(__NAMESPACE__)
31 | ->addNamespace("bandwidthThrottle\\tokenBucket\\util")
32 | ->setTimestamp(1417011228);
33 |
34 | $this->sleepEnvironent = $builder->build();
35 | $this->sleepEnvironent->enable();
36 | }
37 |
38 | protected function tearDown()
39 | {
40 | $this->sleepEnvironent->disable();
41 | }
42 |
43 | /**
44 | * Tests bootstrap() is bootstraps not on already bootstrapped storages.
45 | *
46 | * @test
47 | */
48 | public function testBootstrapOnce()
49 | {
50 | $storage = $this->getMockBuilder(Storage::class)
51 | ->getMock();
52 | $storage->expects($this->any())
53 | ->method("getMutex")
54 | ->willReturn(new NoMutex());
55 | $storage->expects($this->any())
56 | ->method("isBootstrapped")
57 | ->willReturn(true);
58 |
59 | $bucket = new TokenBucket(1, new Rate(1, Rate::SECOND), $storage);
60 |
61 | $storage->expects($this->never())
62 | ->method("bootstrap");
63 |
64 | $bucket->bootstrap();
65 | }
66 |
67 | /**
68 | * Tests bootstrapping sets to 0 tokens.
69 | *
70 | * @test
71 | */
72 | public function testDefaultBootstrap()
73 | {
74 | $rate = new Rate(1, Rate::SECOND);
75 | $tokenBucket = new TokenBucket(10, $rate, new SingleProcessStorage());
76 | $tokenBucket->bootstrap();
77 |
78 | $this->assertFalse($tokenBucket->consume(1));
79 | }
80 |
81 | /**
82 | * Tests bootstrapping with tokens.
83 | *
84 | * @param int $capacity The capacity.
85 | * @param int $tokens The initial amount of tokens.
86 | *
87 | * @test
88 | * @dataProvider provideTestBootstrapWithInitialTokens
89 | */
90 | public function testBootstrapWithInitialTokens($capacity, $tokens)
91 | {
92 | $rate = new Rate(1, Rate::SECOND);
93 | $tokenBucket = new TokenBucket($capacity, $rate, new SingleProcessStorage());
94 | $tokenBucket->bootstrap($tokens);
95 |
96 | $this->assertTrue($tokenBucket->consume($tokens));
97 | $this->assertFalse($tokenBucket->consume(1));
98 | }
99 |
100 | /**
101 | * Returns test cases for testBootstrapWithInitialTokens().
102 | *
103 | * @return int[][] Test cases.
104 | */
105 | public function provideTestBootstrapWithInitialTokens()
106 | {
107 | return [
108 | [10, 1],
109 | [10, 10]
110 | ];
111 | }
112 |
113 | /**
114 | * Tests comsumption of cumulated tokens.
115 | *
116 | * @test
117 | */
118 | public function testConsume()
119 | {
120 | $rate = new Rate(1, Rate::SECOND);
121 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
122 | $bucket->bootstrap(10);
123 |
124 | $this->assertTrue($bucket->consume(1));
125 | $this->assertTrue($bucket->consume(2));
126 | $this->assertTrue($bucket->consume(3));
127 | $this->assertTrue($bucket->consume(4));
128 |
129 | $this->assertFalse($bucket->consume(1));
130 |
131 | sleep(3);
132 | $this->assertFalse($bucket->consume(4, $seconds));
133 | $this->assertEquals(1, $seconds);
134 | }
135 |
136 | /**
137 | * Tests consume() returns the expected amount of seconds to wait.
138 | *
139 | * @test
140 | */
141 | public function testWaitCalculation()
142 | {
143 | $rate = new Rate(1, Rate::SECOND);
144 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
145 | $bucket->bootstrap(1);
146 |
147 | $bucket->consume(3, $seconds);
148 | $this->assertEquals(2, $seconds);
149 | sleep(1);
150 |
151 | $bucket->consume(3, $seconds);
152 | $this->assertEquals(1, $seconds);
153 | sleep(1);
154 |
155 | $bucket->consume(3, $seconds);
156 | $this->assertEquals(0, $seconds);
157 | }
158 |
159 | /**
160 | * Test token rate.
161 | *
162 | * @test
163 | */
164 | public function testWaitingAddsTokens()
165 | {
166 | $rate = new Rate(1, Rate::SECOND);
167 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
168 | $bucket->bootstrap();
169 |
170 | $this->assertFalse($bucket->consume(1));
171 |
172 | sleep(1);
173 | $this->assertTrue($bucket->consume(1));
174 |
175 | sleep(2);
176 | $this->assertTrue($bucket->consume(2));
177 | }
178 |
179 | /**
180 | * Tests consuming insuficient tokens wont remove any token.
181 | *
182 | * @test
183 | */
184 | public function testConsumeInsufficientDontRemoveTokens()
185 | {
186 | $rate = new Rate(1, Rate::SECOND);
187 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
188 | $bucket->bootstrap(1);
189 |
190 | $this->assertFalse($bucket->consume(2, $seconds));
191 | $this->assertEquals(1, $seconds);
192 |
193 | $this->assertFalse($bucket->consume(2, $seconds));
194 | $this->assertEquals(1, $seconds);
195 |
196 | $this->assertTrue($bucket->consume(1));
197 | }
198 |
199 | /**
200 | * Tests consuming tokens.
201 | *
202 | * @test
203 | */
204 | public function testConsumeSufficientRemoveTokens()
205 | {
206 | $rate = new Rate(1, Rate::SECOND);
207 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
208 | $bucket->bootstrap(1);
209 |
210 | $this->assertTrue($bucket->consume(1));
211 | $this->assertFalse($bucket->consume(1, $seconds));
212 | $this->assertEquals(1, $seconds);
213 | }
214 |
215 | /**
216 | * Tests bootstrapping with too many tokens.
217 | *
218 | * @test
219 | * @expectedException \LengthException
220 | */
221 | public function testInitialTokensTooMany()
222 | {
223 | $rate = new Rate(1, Rate::SECOND);
224 | $bucket = new TokenBucket(20, $rate, new SingleProcessStorage());
225 | $bucket->bootstrap(21);
226 | }
227 |
228 | /**
229 | * Tests consuming more than the capacity.
230 | *
231 | * @test
232 | * @expectedException \LengthException
233 | */
234 | public function testConsumeTooMany()
235 | {
236 | $rate = new Rate(1, Rate::SECOND);
237 | $tokenBucket = new TokenBucket(20, $rate, new SingleProcessStorage());
238 | $tokenBucket->bootstrap();
239 |
240 | $tokenBucket->consume(21);
241 | }
242 |
243 | /**
244 | * Test the capacity limit of the bucket
245 | *
246 | * @test
247 | */
248 | public function testCapacity()
249 | {
250 | $rate = new Rate(1, Rate::SECOND);
251 | $tokenBucket = new TokenBucket(10, $rate, new SingleProcessStorage());
252 | $tokenBucket->bootstrap();
253 | sleep(11);
254 |
255 | $this->assertTrue($tokenBucket->consume(10));
256 | $this->assertFalse($tokenBucket->consume(1));
257 | }
258 |
259 | /**
260 | * Tests building a token bucket with an invalid caüacity fails.
261 | *
262 | * @test
263 | * @expectedException InvalidArgumentException
264 | * @dataProvider provideTestInvalidCapacity
265 | */
266 | public function testInvalidCapacity($capacity)
267 | {
268 | $rate = new Rate(1, Rate::SECOND);
269 | new TokenBucket($capacity, $rate, new SingleProcessStorage());
270 | }
271 |
272 | /**
273 | * Provides tests cases for testInvalidCapacity().
274 | *
275 | * @return array Test cases.
276 | */
277 | public function provideTestInvalidCapacity()
278 | {
279 | return [
280 | [0],
281 | [-1],
282 | ];
283 | }
284 |
285 | /**
286 | * After bootstraping, getTokens() should return the initial amount.
287 | *
288 | * @test
289 | */
290 | public function getTokensShouldReturnInitialAmountOnBootstrap()
291 | {
292 | $rate = new Rate(1, Rate::SECOND);
293 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
294 |
295 | $bucket->bootstrap(10);
296 |
297 | $this->assertEquals(10, $bucket->getTokens());
298 | }
299 |
300 | /**
301 | * After one consumtion, getTokens() should return the initial amount - 1.
302 | *
303 | * @test
304 | */
305 | public function getTokensShouldReturnRemainingTokensAfterConsumption()
306 | {
307 | $rate = new Rate(1, Rate::SECOND);
308 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
309 | $bucket->bootstrap(10);
310 |
311 | $bucket->consume(1);
312 |
313 | $this->assertEquals(9, $bucket->getTokens());
314 | }
315 |
316 | /**
317 | * After consuming all, getTokens() should return 0.
318 | *
319 | * @test
320 | */
321 | public function getTokensShouldReturnZeroTokensAfterConsumingAll()
322 | {
323 | $rate = new Rate(1, Rate::SECOND);
324 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
325 | $bucket->bootstrap(10);
326 |
327 | $bucket->consume(10);
328 |
329 | $this->assertEquals(0, $bucket->getTokens());
330 | }
331 |
332 | /**
333 | * After consuming too many, getTokens() should return the same as before.
334 | *
335 | * @test
336 | */
337 | public function getTokensShouldReturnSameAfterConsumingTooMany()
338 | {
339 | $rate = new Rate(1, Rate::SECOND);
340 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
341 | $bucket->bootstrap(10);
342 |
343 | try {
344 | $bucket->consume(11);
345 | $this->fail("Expected an exception.");
346 | } catch (\LengthException $e) {
347 | // expected
348 | }
349 |
350 | $this->assertEquals(10, $bucket->getTokens());
351 | }
352 |
353 | /**
354 | * After waiting on an non full bucket, getTokens() should return more.
355 | *
356 | * @test
357 | */
358 | public function getTokensShouldReturnMoreAfterWaiting()
359 | {
360 | $rate = new Rate(1, Rate::SECOND);
361 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
362 | $bucket->bootstrap(5);
363 |
364 | sleep(1);
365 |
366 | $this->assertEquals(6, $bucket->getTokens());
367 | }
368 |
369 | /**
370 | * After waiting the complete refill period on an empty bucket, getTokens()
371 | * should return the capacity of the bucket.
372 | *
373 | * @test
374 | */
375 | public function getTokensShouldReturnCapacityAfterWaitingRefillPeriod()
376 | {
377 | $rate = new Rate(1, Rate::SECOND);
378 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
379 | $bucket->bootstrap(0);
380 |
381 | sleep(10);
382 |
383 | $this->assertEquals(10, $bucket->getTokens());
384 | }
385 |
386 | /**
387 | * After waiting longer than the complete refill period on an empty bucket,
388 | * getTokens() should return the capacity of the bucket.
389 | *
390 | * @test
391 | */
392 | public function getTokensShouldReturnCapacityAfterWaitingLongerThanRefillPeriod()
393 | {
394 | $rate = new Rate(1, Rate::SECOND);
395 | $bucket = new TokenBucket(10, $rate, new SingleProcessStorage());
396 | $bucket->bootstrap(0);
397 |
398 | sleep(11);
399 |
400 | $this->assertEquals(10, $bucket->getTokens());
401 | }
402 | }
403 |
--------------------------------------------------------------------------------