├── 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 | [![Build Status](https://travis-ci.org/bandwidth-throttle/token-bucket.svg?branch=master)](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 | --------------------------------------------------------------------------------