├── .gitignore ├── .scrutinizer.yml ├── .editorconfig ├── tests ├── bootstrap.php └── Flaps │ ├── Mock │ ├── ViolationHandler.php │ └── Storage.php │ ├── Violation │ ├── PassiveViolationHandlerTest.php │ ├── ExceptionViolationHandlerTest.php │ └── HttpViolationHandlerTest.php │ ├── Throttling │ ├── ViolateAlwaysStrategyTest.php │ └── LeakyBucketStrategyTest.php │ ├── FlapsTest.php │ └── Storage │ ├── DoctrineCacheAdapterTest.php │ └── PredisStorageTest.php ├── phpunit.xml.dist ├── src └── Flaps │ ├── Violation │ ├── ThrottlingViolationException.php │ ├── PassiveViolationHandler.php │ ├── ExceptionViolationHandler.php │ └── HttpViolationHandler.php │ ├── ViolationHandlerInterface.php │ ├── Throttling │ ├── ViolateAlwaysStrategy.php │ ├── LockStrategy.php │ └── LeakyBucketStrategy.php │ ├── ThrottlingStrategyInterface.php │ ├── Flaps.php │ ├── Storage │ ├── DoctrineCacheAdapter.php │ └── PredisStorage.php │ ├── StorageInterface.php │ └── Flap.php ├── .travis.yml ├── LICENSE ├── composer.json ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | phpunit.xml 3 | composer.lock 4 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | tools: 2 | external_code_coverage: 3 | timeout: 600 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = tab 8 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | addPsr4('BehEh\\Flaps\\', __DIR__.DIRECTORY_SEPARATOR.'Flaps'); 5 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | tests 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/Flaps/Mock/ViolationHandler.php: -------------------------------------------------------------------------------- 1 | 10 | * @codeCoverageIgnore 11 | */ 12 | class ThrottlingViolationException extends RuntimeException 13 | { 14 | // intentionally left blank 15 | } 16 | -------------------------------------------------------------------------------- /src/Flaps/Violation/PassiveViolationHandler.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class PassiveViolationHandler implements ViolationHandlerInterface 13 | { 14 | /** 15 | * Handles a violation by returning false. 16 | * @return boolean 17 | */ 18 | public function handleViolation() 19 | { 20 | return false; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Flaps/ViolationHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface ViolationHandlerInterface 11 | { 12 | /** 13 | * Handles a violation by returning some value, throwing an exception and/or executing any other logic. 14 | * @throws \Exception 15 | * @return mixed anything which can indicate the application why the violation occured 16 | */ 17 | public function handleViolation(); 18 | } 19 | -------------------------------------------------------------------------------- /tests/Flaps/Violation/PassiveViolationHandlerTest.php: -------------------------------------------------------------------------------- 1 | handler = new PassiveViolationHandler; 16 | } 17 | 18 | /** 19 | * @covers BehEh\Flaps\Violation\PassiveViolationHandler::handleViolation 20 | */ 21 | public function testHandleViolation() 22 | { 23 | $this->assertFalse($this->handler->handleViolation()); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Flaps/Violation/ExceptionViolationHandler.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ExceptionViolationHandler implements ViolationHandlerInterface 13 | { 14 | /** 15 | * Handles a violation by throwing a ThrottlingViolationException. 16 | * @throws ThrottlingViolationException 17 | */ 18 | public function handleViolation() 19 | { 20 | throw new ThrottlingViolationException(); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /tests/Flaps/Violation/ExceptionViolationHandlerTest.php: -------------------------------------------------------------------------------- 1 | handler = new ExceptionViolationHandler; 16 | } 17 | 18 | /** 19 | * @covers BehEh\Flaps\Violation\ExceptionViolationHandler::handleViolation 20 | * @expectedException BehEh\Flaps\Violation\ThrottlingViolationException 21 | */ 22 | public function testHandleViolation() 23 | { 24 | $this->handler->handleViolation(); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 5.4 4 | - 5.5 5 | - 5.6 6 | - 7.0 7 | 8 | env: 9 | - COVERAGE=0 10 | 11 | matrix: 12 | include: 13 | - php: 7.0 14 | env: COVERAGE=1 15 | 16 | services: 17 | - redis-server 18 | 19 | before_script: 20 | - composer install 21 | 22 | script: 23 | - > 24 | if [[ "$COVERAGE" == "1" ]]; then 25 | php vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover coverage.clover 26 | else 27 | php vendor/bin/phpunit -c phpunit.xml.dist 28 | fi 29 | 30 | after_script: 31 | - > 32 | if [[ "$COVERAGE" == "1" ]]; then 33 | wget https://scrutinizer-ci.com/ocular.phar 34 | php ocular.phar code-coverage:upload --format=php-clover coverage.clover 35 | fi 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Benedict Etzel 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /tests/Flaps/Throttling/ViolateAlwaysStrategyTest.php: -------------------------------------------------------------------------------- 1 | strategy = new ViolateAlwaysStrategy; 16 | } 17 | 18 | /** 19 | * @covers BehEh\Flaps\Throttling\ViolateAlwaysStrategy::isViolator 20 | */ 21 | public function testIsViolator() 22 | { 23 | $this->assertTrue($this->strategy->isViolator('identifier')); 24 | $this->assertTrue($this->strategy->isViolator(null)); 25 | $this->assertTrue($this->strategy->isViolator(true)); 26 | $this->assertTrue($this->strategy->isViolator(false)); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postman/flaps", 3 | "type": "library", 4 | "description": "Modular library for rate limiting requests in applications", 5 | "keywords": ["rate limit", "throttle", "http"], 6 | "homepage": "https://github.com/postmanlabs/flaps", 7 | "license": "ISC", 8 | "authors": [ 9 | { 10 | "name": "Benedict Etzel", 11 | "email": "developer@beheh.de", 12 | "homepage": "https://beheh.de", 13 | "role": "Developer" 14 | } 15 | ], 16 | "require": { 17 | "php": ">=5.3.0" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "~4.0", 21 | "doctrine/cache": "~1.4", 22 | "predis/predis": "~1.0" 23 | }, 24 | "suggest": { 25 | "predis/predis": "Enable Redis as storage system", 26 | "doctrine/cache": "Enables a wide variety of caching systems as storage" 27 | }, 28 | "autoload": { 29 | "psr-4": {"BehEh\\Flaps\\": "src/Flaps"} 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Flaps Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.2.0] - 2017-06-02 10 | ### Added 11 | - Added EditorConfig (http://editorconfig.org/) 12 | - Added an incrementValue to Storage adapters (#6, @regularjack) 13 | 14 | ### Removed 15 | - Automated tests for PHP 5.3 and hhvm 16 | 17 | ## [0.1.1] - 2016-08-11 18 | ### Changed 19 | - Update phpdoc (#3, @Icewild) 20 | 21 | ### Fixed 22 | - Tests (@Icewild) 23 | 24 | ## v0.1.0 - 2015-03-29 25 | - Initial release 26 | 27 | [Unreleased]: https://github.com/beheh/flaps/compare/0.2.0...HEAD 28 | [0.2.0]: https://github.com/beheh/flaps/compare/0.1.1...0.2.0 29 | [0.1.1]: https://github.com/beheh/flaps/compare/v0.1.0...0.1.1 30 | -------------------------------------------------------------------------------- /tests/Flaps/Violation/HttpViolationHandlerTest.php: -------------------------------------------------------------------------------- 1 | handler = $this->getMockBuilder('\BehEh\Flaps\Violation\HttpViolationHandler') 16 | ->setMethods(array('sendHeader', 'callExit')) 17 | ->getMock(); 18 | } 19 | 20 | /** 21 | * @covers BehEh\Flaps\Violation\HttpViolationHandler::handleViolation 22 | */ 23 | public function testHandleViolation() 24 | { 25 | $this->handler->expects($this->once())->method('sendHeader'); 26 | $this->handler->expects($this->once())->method('callExit'); 27 | $this->handler->handleViolation(); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Flaps/Throttling/ViolateAlwaysStrategy.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ViolateAlwaysStrategy implements ThrottlingStrategyInterface 15 | { 16 | /** 17 | * @codeCoverageIgnore 18 | */ 19 | public function setStorage(StorageInterface $storage) 20 | { 21 | return; 22 | } 23 | 24 | /** 25 | * Always returns true. 26 | * @param string $identifier unused 27 | * @return bool always true 28 | */ 29 | public function isViolator($identifier) 30 | { 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Flaps/ThrottlingStrategyInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface ThrottlingStrategyInterface 13 | { 14 | /** 15 | * Returns whether the entity identified by $identifier violates the throttling strategy. 16 | * @param string $identifier the unique name of the entity 17 | * @return bool whether the named entity violates the constraints of this instance 18 | */ 19 | public function isViolator($identifier); 20 | 21 | /** 22 | * Sets the underlying storage system to be used by the strategy. 23 | * @param \BehEh\Flaps\StorageInterface $storage the storage system to use 24 | */ 25 | public function setStorage(StorageInterface $storage); 26 | } 27 | -------------------------------------------------------------------------------- /src/Flaps/Violation/HttpViolationHandler.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class HttpViolationHandler implements ViolationHandlerInterface 13 | { 14 | 15 | /** 16 | * Handles a violation by sending the corresponding HTTP header and exiting. 17 | */ 18 | public function handleViolation() 19 | { 20 | $this->sendHeader(); 21 | $this->callExit(); 22 | } 23 | 24 | /** 25 | * @codeCoverageIgnore 26 | */ 27 | protected function sendHeader() 28 | { 29 | header('HTTP/1.1 429 Too Many Requests'); 30 | header('Content-Type: text/plain'); 31 | } 32 | 33 | /** 34 | * @codeCoverageIgnore 35 | */ 36 | protected function callExit() 37 | { 38 | die('Too many requests'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Flaps/FlapsTest.php: -------------------------------------------------------------------------------- 1 | flaps = new Flaps(new MockStorage()); 19 | } 20 | 21 | /** 22 | * @covers BehEh\Flaps\Flaps::setDefaultViolationHandler 23 | * @covers BehEh\Flaps\Flaps::getFlap 24 | * @covers BehEh\Flaps\Flaps::__get 25 | */ 26 | public function testDefaultViolationHandler() 27 | { 28 | $handler = new MockViolationHandler(); 29 | $this->flaps->setDefaultViolationHandler($handler); 30 | $this->assertInstanceOf('BehEh\Flaps\Flap', $this->flaps->getFlap('default')); 31 | $this->assertInstanceOf('BehEh\Flaps\Flap', $this->flaps->default); 32 | $this->assertSame($handler, $this->flaps->getFlap('default')->getViolationHandler()); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tests/Flaps/Mock/Storage.php: -------------------------------------------------------------------------------- 1 | values = array(); 17 | $this->timestamps = array(); 18 | } 19 | 20 | public function setValue($key, $value) 21 | { 22 | $this->values[$key] = $value; 23 | } 24 | 25 | public function incrementValue($key) 26 | { 27 | $value = $this->getValue($key) + 1; 28 | $this->setValue($key, $value); 29 | 30 | return $value; 31 | } 32 | 33 | public function getValue($key) 34 | { 35 | return isset($this->values[$key]) ? $this->values[$key] : 0; 36 | } 37 | 38 | public function setTimestamp($key, $timestamp) 39 | { 40 | $this->timestamps[$key] = $timestamp; 41 | } 42 | 43 | public function getTimestamp($key) 44 | { 45 | return isset($this->timestamps[$key]) ? $this->timestamps[$key] : 0; 46 | } 47 | 48 | public function expire($key) 49 | { 50 | unset($this->values[$key]); 51 | unset($this->timestamps[$key]); 52 | unset($this->expires[$key]); 53 | } 54 | 55 | public function expireIn($key, $seconds) 56 | { 57 | $this->expires[$key] = $seconds; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Flaps/Flaps.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class Flaps 11 | { 12 | /** 13 | * @var StorageInterface 14 | */ 15 | protected $adapter; 16 | 17 | public function __construct(StorageInterface $adapter) 18 | { 19 | $this->adapter = $adapter; 20 | } 21 | /** 22 | * @var ViolationHandlerInterface 23 | */ 24 | protected $defaultViolationHandler = null; 25 | 26 | /** 27 | * Sets a default violation handler for flaps created in the future. 28 | * @param \BehEh\Flaps\ViolationHandlerInterface $violationHandler 29 | */ 30 | public function setDefaultViolationHandler(ViolationHandlerInterface $violationHandler) 31 | { 32 | $this->defaultViolationHandler = $violationHandler; 33 | } 34 | 35 | /** 36 | * Creates a new Flap and returns it, setting default violation handler., 37 | * @param string $name the name of the flap 38 | * @return \BehEh\Flaps\Flap the created flap 39 | */ 40 | public function getFlap($name) 41 | { 42 | $flap = new Flap($this->adapter, $name); 43 | if ($this->defaultViolationHandler !== null) { 44 | $flap->setViolationHandler($this->defaultViolationHandler); 45 | } 46 | return $flap; 47 | } 48 | 49 | /** 50 | * Creates a new Flap and returns it, setting default violation handler., 51 | * @param string $name the name of the flap 52 | * @return \BehEh\Flaps\Flap 53 | * @see BehEh\Flaps\Flaps::getFlap 54 | */ 55 | public function __get($name) 56 | { 57 | return $this->getFlap($name); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Flaps/Storage/DoctrineCacheAdapter.php: -------------------------------------------------------------------------------- 1 | 12 | * setNamespace('MyApplication'); 18 | * $storage = new DoctrineCacheAdapter($apc); 19 | * 20 | * 21 | * @since 0.1 22 | * @author Benedict Etzel 23 | */ 24 | class DoctrineCacheAdapter implements StorageInterface 25 | { 26 | /** 27 | * @var Cache 28 | */ 29 | protected $cache; 30 | 31 | /** 32 | * Sets up the adapter using the Doctrine cache implementation $cache. 33 | * @param Doctrine\Common\Cache\Cache $cache the cache implementation to use as storage backend 34 | */ 35 | public function __construct(Cache $cache) 36 | { 37 | $this->cache = $cache; 38 | } 39 | 40 | public function setValue($key, $value) 41 | { 42 | $this->cache->save($key, intval($value)); 43 | } 44 | 45 | public function incrementValue($key) 46 | { 47 | throw new \Exception('incrementValue() is not implemented for DoctrineCacheAdapter'); 48 | } 49 | 50 | public function getValue($key) 51 | { 52 | if (!$this->cache->contains($key)) { 53 | return 0; 54 | } 55 | return intval($this->cache->fetch($key)); 56 | } 57 | 58 | public function setTimestamp($key, $timestamp) 59 | { 60 | $this->cache->save($key.':timestamp', floatval($timestamp)); 61 | } 62 | 63 | public function getTimestamp($key) 64 | { 65 | if (!$this->cache->contains($key.':timestamp')) { 66 | return (float) 0; 67 | } 68 | return floatval($this->cache->fetch($key.':timestamp')); 69 | } 70 | 71 | public function expire($key) 72 | { 73 | $this->cache->delete($key.':timestamp'); 74 | return $this->cache->delete($key); 75 | } 76 | 77 | /** 78 | * @codeCoverageIgnore 79 | */ 80 | public function expireIn($key, $seconds) 81 | { 82 | return false; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Flaps/StorageInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface StorageInterface 13 | { 14 | /** 15 | * Sets the value identified by $key to $value in the storage backend. 16 | * @param string $key the unique key to set 17 | * @param int $value the value to set it to 18 | */ 19 | public function setValue($key, $value); 20 | 21 | /** 22 | * Increments the number stored at $key by one. 23 | * If the key does not exist, it is set to 0 before performing the operation. 24 | * @param string $key the unique key to increment 25 | * @return int the value associated with the key after the increment 26 | */ 27 | public function incrementValue($key); 28 | 29 | /** 30 | * Returns the value identified by $key in the storage backend. 31 | * @param string $key the unique key to return the value from 32 | * @return int the value associated with the key or 0, in none has been set 33 | */ 34 | public function getValue($key); 35 | 36 | /** 37 | * Sets the timestamp indicating when the value identified by $key last changed. 38 | * @param string $key the unique key associated with a value 39 | * @param float $timestamp the time to set 40 | */ 41 | public function setTimestamp($key, $timestamp); 42 | 43 | /** 44 | * Returns the timestamp indicating when the value identified by$key last changed. 45 | * @param string $key the unique key associated with a value 46 | * @return float the previously set time or 0 47 | */ 48 | public function getTimestamp($key); 49 | 50 | /** 51 | * Immediately removes both value and timestamp associated with $key. 52 | * @param string $key the unique key identified with value and timestamp 53 | */ 54 | public function expire($key); 55 | 56 | /** 57 | * Removes both value and timestamp associated with $key in $seconds. 58 | * @param string $key the unique key identified with value and timestamp 59 | * @param int $seconds the amount of seconds in which associated value and timestamp should expire 60 | */ 61 | public function expireIn($key, $seconds); 62 | } 63 | -------------------------------------------------------------------------------- /tests/Flaps/Storage/DoctrineCacheAdapterTest.php: -------------------------------------------------------------------------------- 1 | cache = new ArrayCache(); 24 | $this->storage = new DoctrineCacheAdapter($this->cache); 25 | } 26 | 27 | /** 28 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::setValue 29 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::getValue 30 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::expire 31 | */ 32 | public function testValue() 33 | { 34 | $this->assertFalse($this->cache->contains('key')); 35 | $this->assertSame(0, $this->storage->getValue('key')); 36 | 37 | $this->storage->setValue('key', 1); 38 | $this->assertTrue($this->cache->contains('key')); 39 | $this->assertSame(1, $this->storage->getValue('key')); 40 | 41 | $this->storage->setValue('key', 5); 42 | $this->assertSame(5, $this->storage->getValue('key')); 43 | 44 | $this->storage->expire('key'); 45 | $this->assertFalse($this->cache->contains('key')); 46 | } 47 | 48 | /** 49 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::incrementValue 50 | */ 51 | public function testIncrementValue() 52 | { 53 | $this->setExpectedExceptionRegExp('Exception', '/not implemented/'); 54 | $this->storage->incrementValue('key'); 55 | } 56 | 57 | /** 58 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::setTimestamp 59 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::getTimestamp 60 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::expire 61 | */ 62 | public function testTimestamp() 63 | { 64 | $this->assertFalse($this->cache->contains('key')); 65 | $this->assertSame(0.0, $this->storage->getTimestamp('key')); 66 | 67 | $this->storage->setTimestamp('key', 1425829426.0); 68 | $this->assertSame(1425829426.0, $this->storage->getTimestamp('key')); 69 | 70 | $this->storage->expire('key'); 71 | $this->assertFalse($this->cache->contains('key:timestamp')); 72 | } 73 | 74 | /** 75 | * @covers BehEh\Flaps\Storage\DoctrineCacheAdapter::expire 76 | */ 77 | public function testExpire() 78 | { 79 | $this->assertFalse($this->cache->contains('key')); 80 | $this->assertFalse($this->cache->contains('key:timestamp')); 81 | 82 | $this->storage->setValue('key', 1); 83 | $this->storage->setTimestamp('key', 1425829426.0); 84 | 85 | $this->assertTrue($this->cache->contains('key')); 86 | $this->assertTrue($this->cache->contains('key:timestamp')); 87 | 88 | $this->assertTrue($this->storage->expire('key')); 89 | 90 | $this->assertFalse($this->cache->contains('key')); 91 | $this->assertFalse($this->cache->contains('key:timestamp')); 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/Flaps/Storage/PredisStorage.php: -------------------------------------------------------------------------------- 1 | 12 | * 18 | * 19 | * @since 0.1 20 | * @author Benedict Etzel 21 | */ 22 | class PredisStorage implements StorageInterface 23 | { 24 | /** 25 | * @var Client 26 | */ 27 | protected $client; 28 | 29 | /** 30 | * Sets up the class using the redis instance connected to the predis $client. 31 | * @param Predis\Client $client 32 | * @param array $options an array of options 33 | * @see BehEh\Flaps\PredisStorage::configure 34 | */ 35 | public function __construct(Client $client, array $options = array()) 36 | { 37 | $this->client = $client; 38 | $this->configure($options); 39 | } 40 | 41 | /** 42 | * @var array 43 | */ 44 | protected $options; 45 | 46 | /** 47 | * Configures this class with some options: 48 | * 49 | * 50 | *
prefixthe prefix to apply to all unique keys
51 | * @param array $options the key value pairs of options for this class 52 | */ 53 | public function configure(array $options) 54 | { 55 | $this->options = array_merge(array( 56 | 'prefix' => 'flaps:' 57 | ), $options); 58 | } 59 | 60 | private function prefixKey($key) 61 | { 62 | return $this->options['prefix'].$key; 63 | } 64 | 65 | private function prefixTimestamp($timestamp) 66 | { 67 | return $this->prefixKey($timestamp.':timestamp'); 68 | } 69 | 70 | public function setValue($key, $value) 71 | { 72 | $this->client->set($this->prefixKey($key), intval($value)); 73 | } 74 | 75 | public function incrementValue($key) 76 | { 77 | return intval($this->client->incr($this->prefixKey($key))); 78 | } 79 | 80 | public function getValue($key) 81 | { 82 | return intval($this->client->get($this->prefixKey($key))); 83 | } 84 | 85 | public function setTimestamp($key, $timestamp) 86 | { 87 | $this->client->set($this->prefixTimestamp($key), floatval($timestamp)); 88 | } 89 | 90 | public function getTimestamp($key) 91 | { 92 | return floatval($this->client->get($this->prefixTimestamp($key))); 93 | } 94 | 95 | public function expire($key) 96 | { 97 | $this->client->del($this->prefixTimestamp($key)); 98 | return (int) $this->client->del($this->prefixKey($key)) === 1; 99 | } 100 | 101 | public function expireIn($key, $seconds) 102 | { 103 | $redisTime = $this->client->time(); 104 | $at = ceil($redisTime[0] + $seconds); 105 | $this->client->expireat($this->prefixTimestamp($key), $at); 106 | return (int) $this->client->expireat($this->prefixKey($key), $at) === 1; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Flaps/Flap.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Flap 13 | { 14 | /** 15 | * @var StorageInterface 16 | */ 17 | protected $storage; 18 | 19 | /** 20 | * @var string 21 | */ 22 | protected $name; 23 | 24 | /** 25 | * 26 | * @param StorageInterface $storage 27 | * @param string $name 28 | */ 29 | public function __construct(StorageInterface $storage, $name) 30 | { 31 | $this->storage = $storage; 32 | $this->name = $name; 33 | } 34 | 35 | /** 36 | * @var ThrottlingStrategyInterface[] 37 | */ 38 | protected $throttlingStrategies = array(); 39 | 40 | /** 41 | * @var ViolationHandlerInterface 42 | */ 43 | protected $violationHandler = null; 44 | 45 | /** 46 | * Adds the throttling strategy to the internal list of throttling strategies. 47 | * @param BehEh\Flaps\ViolationHandlerInterface 48 | */ 49 | public function pushThrottlingStrategy(ThrottlingStrategyInterface $throttlingStrategy) 50 | { 51 | $throttlingStrategy->setStorage($this->storage); 52 | $this->throttlingStrategies[] = $throttlingStrategy; 53 | } 54 | 55 | /** 56 | * Sets the violation handler. 57 | * @param ViolationHandlerInterface $violationHandler the violation handler to use 58 | */ 59 | public function setViolationHandler(ViolationHandlerInterface $violationHandler) 60 | { 61 | $this->violationHandler = $violationHandler; 62 | } 63 | 64 | /** 65 | * Returns the violation handler. 66 | * @return ViolationHandlerInterface the violation handler, if set 67 | */ 68 | public function getViolationHandler() 69 | { 70 | return $this->violationHandler; 71 | } 72 | 73 | /** 74 | * Ensures a violation handler is set. If none is set, default to HttpViolationHandler. 75 | */ 76 | protected function ensureViolationHandler() 77 | { 78 | if ($this->violationHandler === null) { 79 | $this->violationHandler = new HttpViolationHandler(); 80 | } 81 | } 82 | 83 | /** 84 | * Requests violation handling from the violation handler if identifier violates any throttling strategy. 85 | * @param string $identifier 86 | * @return mixed true, if no throttling strategy is violated, otherwise the return value of the violation handler's handleViolation 87 | */ 88 | public function limit($identifier) 89 | { 90 | if ($this->isViolator($identifier)) { 91 | $this->ensureViolationHandler(); 92 | return $this->violationHandler->handleViolation(); 93 | } 94 | return true; 95 | } 96 | 97 | /** 98 | * Checks whether the entity violates any throttling strategy. 99 | * @param string $identifier a unique string identifying the entity to check 100 | * @return bool true if the entity violates any strategy 101 | */ 102 | public function isViolator($identifier) 103 | { 104 | $violation = false; 105 | foreach ($this->throttlingStrategies as $throttlingStrategy) { 106 | /** @var ThrottlingStrategyInterface $throttlingHandler */ 107 | if ($throttlingStrategy->isViolator($this->name.':'.$identifier)) { 108 | $violation = true; 109 | } 110 | } 111 | return $violation; 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/Flaps/Throttling/LockStrategy.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class LockStrategy implements ThrottlingStrategyInterface 16 | { 17 | /** 18 | * @var float 19 | */ 20 | protected $timeSpan; 21 | 22 | /** 23 | * Sets the timespan in which the defined number of requests is allowed per single entity. 24 | * @param float|string $timeSpan 25 | * @throws InvalidArgumentException 26 | */ 27 | public function setTimeSpan($timeSpan) 28 | { 29 | if (is_string($timeSpan)) { 30 | $timeSpan = self::parseTime($timeSpan); 31 | } 32 | if (!is_numeric($timeSpan)) { 33 | throw new InvalidArgumentException('timespan is not numeric'); 34 | } 35 | $timeSpan = floatval($timeSpan); 36 | if ($timeSpan <= 0) { 37 | throw new InvalidArgumentException('timespan cannot be 0 or less'); 38 | } 39 | $this->timeSpan = $timeSpan; 40 | } 41 | 42 | /** 43 | * Returns the previously set timespan. 44 | * @return float 45 | */ 46 | public function getTimeSpan() 47 | { 48 | return (float) $this->timeSpan; 49 | } 50 | 51 | /** 52 | * Sets the lock for $lockDuration 53 | * @param int|string $lockDuration tither the amount of seconds or a string such as "10s", "5m" or "1h" 54 | * @throws InvalidArgumentException 55 | * @see LeakyBucketStrategy::setTimeSpan 56 | */ 57 | public function __construct($lockDuration) 58 | { 59 | $this->setTimeSpan($lockDuration); 60 | } 61 | 62 | /** 63 | * @var StorageInterface 64 | */ 65 | protected $storage; 66 | 67 | public function setStorage(StorageInterface $storage) 68 | { 69 | $this->storage = $storage; 70 | } 71 | 72 | /** 73 | * Parses a timespan string such as "10s", "5m" or "1h" and returns the amount of seconds. 74 | * @param string $timeSpan the time span to parse to seconds 75 | * @return float|null the number of seconds or null, if $timeSpan couldn't be parsed 76 | */ 77 | public static function parseTime($timeSpan) 78 | { 79 | $times = array('s' => 1, 'm' => 60, 'h' => 3600, 'd' => 86400, 'w' => 604800); 80 | $matches = array(); 81 | if (is_numeric($timeSpan)) { 82 | return $timeSpan; 83 | } 84 | if (preg_match('/^((\d+)?(\.\d+)?)('.implode('|', array_keys($times)).')$/', 85 | $timeSpan, $matches)) { 86 | return floatval($matches[1]) * $times[$matches[4]]; 87 | } 88 | return null; 89 | } 90 | 91 | /** 92 | * Returns whether entity exceeds its allowed request capacity with this request. 93 | * @param string $identifier the identifer of the entity to check 94 | * @return bool true if this requests exceeds the number of requests allowed 95 | * @throws LogicException if no storage has been set 96 | */ 97 | public function isViolator($identifier) 98 | { 99 | if ($this->storage === null) { 100 | throw new LogicException('no storage set'); 101 | } 102 | 103 | $toCountOverflows = true; 104 | 105 | $time = microtime(true); 106 | $timestamp = $time; 107 | $toBlock = false; 108 | 109 | $timeSpan = $this->timeSpan; 110 | $isLockSet = $this->storage->getValue($identifier); 111 | 112 | if($isLockSet == 0) { 113 | // no lock is set yet 114 | // set the lock and return false 115 | $this->storage->setValue($identifier, 1); 116 | $this->storage->setTimestamp($identifier, $timestamp); 117 | $this->storage->expireIn($identifier, $this->timeSpan); 118 | return false; 119 | } 120 | return true; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Flaps/Storage/PredisStorageTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 23 | $this->storage = new PredisStorage($this->client, array('prefix' => '')); 24 | 25 | $this->client->del('key'); 26 | $this->client->del('key:timestamp'); 27 | } 28 | 29 | /** 30 | * @covers BehEh\Flaps\Storage\PredisStorage::setValue 31 | * @covers BehEh\Flaps\Storage\PredisStorage::incrementValue 32 | * @covers BehEh\Flaps\Storage\PredisStorage::getValue 33 | * @covers BehEh\Flaps\Storage\PredisStorage::expire 34 | */ 35 | public function testValue() 36 | { 37 | $this->assertEquals(0, $this->client->exists('key')); 38 | $this->assertSame(0, $this->storage->getValue('key')); 39 | 40 | $this->storage->setValue('key', 1); 41 | $this->assertEquals(1, $this->client->exists('key')); 42 | $this->assertSame(1, $this->storage->getValue('key')); 43 | 44 | $this->storage->incrementValue('key'); 45 | $this->assertSame(2, $this->storage->getValue('key')); 46 | 47 | $this->storage->setValue('key', 5); 48 | $this->assertSame(5, $this->storage->getValue('key')); 49 | 50 | $this->storage->expire('key'); 51 | $this->assertEquals(0, $this->client->exists('key')); 52 | } 53 | 54 | /** 55 | * @covers BehEh\Flaps\Storage\PredisStorage::setTimestamp 56 | * @covers BehEh\Flaps\Storage\PredisStorage::getTimestamp 57 | * @covers BehEh\Flaps\Storage\PredisStorage::expire 58 | */ 59 | public function testTimestamp() 60 | { 61 | $this->assertEquals(0, $this->client->exists('key')); 62 | $this->assertSame(0.0, $this->storage->getTimestamp('key')); 63 | 64 | $this->storage->setTimestamp('key', 1425829426.0); 65 | $this->assertSame(1425829426.0, $this->storage->getTimestamp('key')); 66 | 67 | $this->storage->expire('key'); 68 | $this->assertEquals(0, $this->client->exists('key:timestamp')); 69 | } 70 | 71 | /** 72 | * @covers BehEh\Flaps\Storage\PredisStorage::expire 73 | */ 74 | public function testExpire() 75 | { 76 | $this->assertEquals(0, $this->client->exists('key')); 77 | $this->assertEquals(0, $this->client->exists('key:timestamp')); 78 | 79 | $this->storage->setValue('key', 1); 80 | $this->storage->setTimestamp('key', 1425829426.0); 81 | 82 | $this->assertEquals(1, $this->client->exists('key')); 83 | $this->assertEquals(1, $this->client->exists('key:timestamp')); 84 | 85 | $this->assertEquals(1, $this->storage->expire('key')); 86 | 87 | $this->assertEquals(0, $this->client->exists('key')); 88 | $this->assertEquals(0, $this->client->exists('key:timestamp')); 89 | 90 | $this->assertEquals(0, $this->storage->expire('key')); 91 | } 92 | 93 | /** 94 | * @covers BehEh\Flaps\Storage\PredisStorage::expireIn 95 | */ 96 | public function testExpireIn() 97 | { 98 | $this->assertEquals(0, $this->client->exists('key')); 99 | $this->assertEquals(0, $this->client->exists('key:timestamp')); 100 | 101 | $this->storage->setValue('key', 1); 102 | $this->storage->setTimestamp('key', 1425829426.0); 103 | 104 | $this->assertEquals(1, $this->client->exists('key')); 105 | $this->assertEquals(1, $this->client->exists('key:timestamp')); 106 | 107 | $this->assertEquals(1, $this->storage->expireIn('key', 0)); 108 | 109 | $this->assertEquals(0, $this->client->exists('key')); 110 | $this->assertEquals(0, $this->client->exists('key:timestamp')); 111 | 112 | $this->assertEquals(0, $this->storage->expireIn('key', 0)); 113 | } 114 | 115 | protected function tearDown() 116 | { 117 | $this->client->del('key'); 118 | $this->client->del('key:timestamp'); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/Flaps/Throttling/LeakyBucketStrategy.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class LeakyBucketStrategy implements ThrottlingStrategyInterface 19 | { 20 | /** 21 | * @var int 22 | */ 23 | protected $requestsPerTimeSpan; 24 | 25 | /** 26 | * Sets the maximum number of requests allowed per timespan for a single entity. 27 | * @param int $requests 28 | * @throws \InvalidArgumentException 29 | */ 30 | public function setRequestsPerTimeSpan($requests) 31 | { 32 | if (!is_numeric($requests)) { 33 | throw new InvalidArgumentException('requests per timespan is not numeric'); 34 | } 35 | $requests = (int) $requests; 36 | if ($requests < 1) { 37 | throw new InvalidArgumentException('requests per timespan cannot be smaller than 1'); 38 | } 39 | $this->requestsPerTimeSpan = $requests; 40 | } 41 | 42 | /** 43 | * Returns the previously set number of requests per timespan. 44 | * @return int 45 | */ 46 | public function getRequestsPerTimeSpan() 47 | { 48 | return $this->requestsPerTimeSpan; 49 | } 50 | 51 | /** 52 | * @var float 53 | */ 54 | protected $timeSpan; 55 | 56 | /** 57 | * Sets the timespan in which the defined number of requests is allowed per single entity. 58 | * @param float|string $timeSpan 59 | * @throws InvalidArgumentException 60 | */ 61 | public function setTimeSpan($timeSpan) 62 | { 63 | if (is_string($timeSpan)) { 64 | $timeSpan = self::parseTime($timeSpan); 65 | } 66 | if (!is_numeric($timeSpan)) { 67 | throw new InvalidArgumentException('timespan is not numeric'); 68 | } 69 | $timeSpan = floatval($timeSpan); 70 | if ($timeSpan <= 0) { 71 | throw new InvalidArgumentException('timespan cannot be 0 or less'); 72 | } 73 | $this->timeSpan = $timeSpan; 74 | } 75 | 76 | /** 77 | * Returns the previously set timespan. 78 | * @return float 79 | */ 80 | public function getTimeSpan() 81 | { 82 | return (float) $this->timeSpan; 83 | } 84 | 85 | /** 86 | * Sets the strategy up with $requests allowed per $timeSpan 87 | * @param int $requests the requests allowed per time span 88 | * @param int|string $timeSpan tither the amount of seconds or a string such as "10s", "5m" or "1h" 89 | * @throws InvalidArgumentException 90 | * @see LeakyBucketStrategy::setRequestsPerTimeSpan 91 | * @see LeakyBucketStrategy::setTimeSpan 92 | */ 93 | public function __construct($requests, $timeSpan) 94 | { 95 | $this->setRequestsPerTimeSpan($requests); 96 | $this->setTimeSpan($timeSpan); 97 | } 98 | 99 | /** 100 | * @var StorageInterface 101 | */ 102 | protected $storage; 103 | 104 | public function setStorage(StorageInterface $storage) 105 | { 106 | $this->storage = $storage; 107 | } 108 | 109 | /** 110 | * Parses a timespan string such as "10s", "5m" or "1h" and returns the amount of seconds. 111 | * @param string $timeSpan the time span to parse to seconds 112 | * @return float|null the number of seconds or null, if $timeSpan couldn't be parsed 113 | */ 114 | public static function parseTime($timeSpan) 115 | { 116 | $times = array('s' => 1, 'm' => 60, 'h' => 3600, 'd' => 86400, 'w' => 604800); 117 | $matches = array(); 118 | if (is_numeric($timeSpan)) { 119 | return $timeSpan; 120 | } 121 | if (preg_match('/^((\d+)?(\.\d+)?)('.implode('|', array_keys($times)).')$/', 122 | $timeSpan, $matches)) { 123 | return floatval($matches[1]) * $times[$matches[4]]; 124 | } 125 | return null; 126 | } 127 | 128 | /** 129 | * Returns whether entity exceeds its allowed request capacity with this request. 130 | * @param string $identifier the identifer of the entity to check 131 | * @return bool true if this requests exceeds the number of requests allowed 132 | * @throws LogicException if no storage has been set 133 | */ 134 | public function isViolator($identifier) 135 | { 136 | if ($this->storage === null) { 137 | throw new LogicException('no storage set'); 138 | } 139 | 140 | $toCountOverflows = true; 141 | 142 | $time = microtime(true); 143 | $timestamp = $time; 144 | $toBlock = false; 145 | 146 | $rate = (float) $this->requestsPerTimeSpan / $this->timeSpan; 147 | $identifier = 'leaky:'.sha1($rate.$identifier); 148 | 149 | $requestCount = $this->storage->getValue($identifier); 150 | 151 | $oldTimestamp = $this->storage->getTimestamp($identifier); 152 | if ($requestCount === 0) { 153 | $oldTimestamp = $time; 154 | } 155 | 156 | $secondsSince = $time - $oldTimestamp; 157 | $reduceBy = floor($this->requestsPerTimeSpan * ($secondsSince / $this->timeSpan)); 158 | $requestCount = max($requestCount - $reduceBy, 0); 159 | 160 | if ($requestCount + 1 > $this->requestsPerTimeSpan) { 161 | $toBlock = true; 162 | if ($toCountOverflows) { 163 | return $toBlock; 164 | } 165 | } 166 | 167 | $requestCount++; 168 | 169 | $this->storage->setValue($identifier, $requestCount); 170 | $this->storage->setTimestamp($identifier, $timestamp); 171 | $this->storage->expireIn($identifier, $this->timeSpan - $secondsSince); 172 | 173 | return $toBlock; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /tests/Flaps/Throttling/LeakyBucketStrategyTest.php: -------------------------------------------------------------------------------- 1 | strategy = new LeakyBucketStrategy(1, 1); 15 | } 16 | 17 | /** 18 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setTimeSpan 19 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::getTimeSpan 20 | */ 21 | public function testSetTimeSpan() 22 | { 23 | $this->strategy->setTimeSpan(1); 24 | $this->assertEquals(1, $this->strategy->getTimeSpan()); 25 | $this->strategy->setTimeSpan(2); 26 | $this->assertEquals(2, $this->strategy->getTimeSpan()); 27 | $this->strategy->setTimeSpan(2.5); 28 | $this->assertEquals(2.5, $this->strategy->getTimeSpan()); 29 | $this->strategy->setTimeSpan(2.1); 30 | $this->assertEquals(2.1, $this->strategy->getTimeSpan()); 31 | $this->strategy->setTimeSpan(2.9); 32 | $this->assertEquals(2.9, $this->strategy->getTimeSpan()); 33 | $this->strategy->setTimeSpan('1m'); 34 | $this->assertEquals(60, $this->strategy->getTimeSpan()); 35 | } 36 | 37 | /** 38 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setTimeSpan 39 | * @expectedException InvalidArgumentException 40 | */ 41 | public function testSetTimeSpanWithZero() 42 | { 43 | $this->strategy->setTimeSpan(0); 44 | } 45 | 46 | /** 47 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setTimeSpan 48 | * @expectedException InvalidArgumentException 49 | */ 50 | public function testSetTimeSpanWithNegative() 51 | { 52 | $this->strategy->setTimeSpan(-1); 53 | } 54 | 55 | /** 56 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setTimeSpan 57 | * @expectedException InvalidArgumentException 58 | */ 59 | public function testSetTimeSpanWithArray() 60 | { 61 | $this->strategy->setTimeSpan(array()); 62 | } 63 | 64 | /** 65 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setRequestsPerTimeSpan 66 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::getRequestsPerTimeSpan 67 | */ 68 | public function testSetRequestsPerTimeSpan() 69 | { 70 | $this->strategy->setRequestsPerTimeSpan(1); 71 | $this->assertEquals(1, $this->strategy->getRequestsPerTimeSpan()); 72 | $this->strategy->setRequestsPerTimeSpan(2); 73 | $this->assertEquals(2, $this->strategy->getRequestsPerTimeSpan()); 74 | $this->strategy->setRequestsPerTimeSpan(2.5); 75 | $this->assertEquals(2, $this->strategy->getRequestsPerTimeSpan()); 76 | $this->strategy->setRequestsPerTimeSpan(2.1); 77 | $this->assertEquals(2, $this->strategy->getRequestsPerTimeSpan()); 78 | $this->strategy->setRequestsPerTimeSpan(2.9); 79 | $this->assertEquals(2, $this->strategy->getRequestsPerTimeSpan()); 80 | } 81 | 82 | /** 83 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setRequestsPerTimeSpan 84 | * @expectedException InvalidArgumentException 85 | */ 86 | public function testSetRequestsPerTimeSpanWithZero() 87 | { 88 | $this->strategy->setRequestsPerTimeSpan(0); 89 | } 90 | 91 | /** 92 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setRequestsPerTimeSpan 93 | * @expectedException InvalidArgumentException 94 | */ 95 | public function testSetRequestsPerTimeSpanWithNegative() 96 | { 97 | $this->strategy->setRequestsPerTimeSpan(-1); 98 | } 99 | 100 | /** 101 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::setRequestsPerTimeSpan 102 | * @expectedException InvalidArgumentException 103 | */ 104 | public function testSetRequestsPerTimeSpanWithArray() 105 | { 106 | $this->strategy->setRequestsPerTimeSpan(array()); 107 | } 108 | 109 | /** 110 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::parseTime 111 | */ 112 | public function testParseTime() 113 | { 114 | $this->assertEquals(0, LeakyBucketStrategy::parseTime(0)); 115 | $this->assertEquals(0, LeakyBucketStrategy::parseTime('0')); 116 | $this->assertEquals(0, LeakyBucketStrategy::parseTime('0s')); 117 | $this->assertEquals(1, LeakyBucketStrategy::parseTime(1)); 118 | $this->assertEquals(1, LeakyBucketStrategy::parseTime('1')); 119 | $this->assertEquals(1, LeakyBucketStrategy::parseTime('1s')); 120 | $this->assertEquals(2, LeakyBucketStrategy::parseTime('2s')); 121 | $this->assertEquals(60, LeakyBucketStrategy::parseTime('1m')); 122 | $this->assertEquals(90, LeakyBucketStrategy::parseTime('1.5m')); 123 | $this->assertNull(LeakyBucketStrategy::parseTime('invalid')); 124 | $this->assertNull(LeakyBucketStrategy::parseTime('1 m')); 125 | $this->assertNull(LeakyBucketStrategy::parseTime('1k')); 126 | } 127 | 128 | /** 129 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::isViolator 130 | * @expectedException LogicException 131 | */ 132 | public function testIsViolatorWithoutStorage() 133 | { 134 | $instance = new LeakyBucketStrategy(1, '1s'); 135 | $instance->isViolator('BehEh'); 136 | } 137 | 138 | /** 139 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::isViolator 140 | * @expectedException LogicException 141 | */ 142 | public function testIsViolatorWithZeroRate() 143 | { 144 | $instance = new LeakyBucketStrategy(0, 0); 145 | $instance->setStorage(new MockStorage); 146 | $instance->isViolator('BehEh'); 147 | } 148 | 149 | /** 150 | * @covers BehEh\Flaps\Throttling\LeakyBucketStrategy::isViolator 151 | */ 152 | public function testIsViolator() 153 | { 154 | $instance = new LeakyBucketStrategy(1, '1s'); 155 | $instance->setStorage(new MockStorage); 156 | $this->assertFalse($instance->isViolator('BehEh')); 157 | $this->assertTrue($instance->isViolator('BehEh')); 158 | usleep(500 * 1000); 159 | $this->assertTrue($instance->isViolator('BehEh')); 160 | usleep(500 * 1000); 161 | $this->assertFalse($instance->isViolator('BehEh')); 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flaps 2 | 3 | [![Travis](https://img.shields.io/travis/beheh/flaps/master.svg?style=flat-square)](https://travis-ci.org/beheh/flaps) 4 | [![Scrutinizer Coverage](https://img.shields.io/scrutinizer/coverage/g/beheh/flaps/master.svg?style=flat-square)](https://scrutinizer-ci.com/g/beheh/flaps/?branch=master) 5 | [![Scrutinizer](https://img.shields.io/scrutinizer/g/beheh/flaps/master.svg?style=flat-square)](https://scrutinizer-ci.com/g/beheh/flaps/?branch=master) 6 | [![Packagist](https://img.shields.io/packagist/v/beheh/flaps.svg?style=flat-square)](https://packagist.org/packages/beheh/flaps) 7 | [![Packagist](https://img.shields.io/packagist/l/beheh/flaps.svg?style=flat-square)](https://packagist.org/packages/beheh/flaps) 8 | 9 | Flaps is a modular library for rate limiting requests in PHP applications. 10 | 11 | The library supports custom storage backends, throttling strategies and violation handlers for flexible integration into any project. 12 | 13 | Developed by [@beheh](https://github.com/beheh) and licensed under the ISC license. 14 | 15 | ## Requirements 16 | 17 | - PHP 5.4 or newer 18 | - Persistent-ish storage (e.g. Redis, APC or anything supported by _[Doctrine\Cache](http://doctrine-common.readthedocs.org/en/latest/reference/caching.html)_) 19 | - Composer 20 | 21 | ## Basic usage 22 | 23 | ```php 24 | use Predis\Client; 25 | use BehEh\Flaps\Storage\PredisStorage; 26 | use BehEh\Flaps\Flaps; 27 | use BehEh\Flaps\Throttling\LeakyBucketStrategy; 28 | 29 | // setup with Redis as storage backend 30 | $storage = new PredisStorage(new Client()); 31 | $flaps = new Flaps($storage); 32 | 33 | // allow 3 requests per 5 seconds 34 | $flaps->login->pushThrottlingStrategy(new LeakyBucketStrategy(3, '5s')); 35 | //or $flaps->__get('login')->pushThrottlingStrategy(...) 36 | 37 | // limit by ip (default: send "HTTP/1.1 429 Too Many Requests" and die() on violation) 38 | $flaps->login->limit($_SERVER['REMOTE_ADDR']); 39 | ``` 40 | 41 | ## Why rate limit? 42 | 43 | There are many benefits from rate limiting your web application. At any point in time your server(s) could be hit by a huge number of requests from one or many clients. These could be: 44 | - Malicious clients trying to degrade your applications performance 45 | - Malicious clients bruteforcing user credentials 46 | - Bugged clients repeating requests over and over again 47 | - Automated web crawlers enumerating usernames or email adresses 48 | - Penetration frameworks testing for vulnerabilities 49 | - Bots registering a large number of users 50 | - Bots spamming links to malicious sites 51 | 52 | Most of these problems can be solved in a variety of ways, for example by using a spam filter or a fully configured firewall. Rate limiting is nevertheless a basic tool for improving application security, but offers no full protection. 53 | 54 | ## Advanced examples 55 | 56 | ### Application-handled violation 57 | 58 | ```php 59 | use BehEh\Flaps\Throttling\LeakyBucketStrategy; 60 | use BehEh\Flaps\Violation\PassiveViolationHandler; 61 | 62 | $flap = $flaps->__get('api'); 63 | $flap->pushThrottlingStrategy(new LeakyBucketStrategy(15, '10s')); 64 | $flap->setViolationHandler(new PassiveViolationHandler); 65 | if (!$flap->limit(filter_var(INPUT_GET, 'api_key'))) { 66 | die(json_encode(array('error' => 'too many requests'))); 67 | } 68 | ``` 69 | 70 | ### Multiple throttling strategies 71 | 72 | ```php 73 | use BehEh\Flaps\Throttling\LeakyBucketStrategy; 74 | 75 | $flap = $flaps->__get('add_comment'); 76 | $flap->pushThrottlingStrategy(new LeakyBucketStrategy(1, '30s')); 77 | $flap->pushThrottlingStrategy(new LeakyBucketStrategy(10, '10m')); 78 | $flap->limit($userid); 79 | ``` 80 | 81 | ## Storage 82 | 83 | ### Redis 84 | 85 | The easiest storage system to get started is Redis (via [nrk/predis](https://github.com/nrk/predis)): 86 | 87 | ```php 88 | use Predis\Client; 89 | use BehEh\Flaps\Storage\PredisStorage; 90 | use BehEh\Flaps\Flaps; 91 | 92 | $storage = new PredisStorage(new Client('tcp://10.0.0.1:6379')); 93 | $flaps = new Flaps($storage); 94 | ``` 95 | 96 | Don't forget to `composer require predis/predis`. 97 | 98 | ### Doctrine cache 99 | 100 | You can use any of the [Doctrine cache implementations](http://doctrine-common.readthedocs.org/en/latest/reference/caching.html) by using the _DoctrineCacheAdapter_: 101 | 102 | ```php 103 | use Doctrine\Common\Cache\ApcCache; 104 | use BehEh\Flaps\Storage\DoctrineCacheAdapter; 105 | use BehEh\Flaps\Flaps; 106 | 107 | $apc = new ApcCache(); 108 | $apc->setNamespace('MyApplication'); 109 | $storage = new DoctrineCacheAdapter($apc); 110 | $flaps = new Flaps($storage); 111 | ``` 112 | 113 | The Doctrine caching implementations can be installed with `composer require doctrine/cache`. 114 | 115 | ### Custom storage 116 | 117 | Alternatively you can use your own storage system by implementing _BehEh\Flaps\StorageInterface_. 118 | 119 | ## Throttling strategies 120 | 121 | ### Leaky bucket strategy 122 | 123 | This strategy is based on the leaky bucket algorithm. Each unique identifier of a flap corresponds to a leaky bucket. 124 | Clients can now access the buckets as much as they like, inserting water for every request. If a request would cause the bucket to overflow, it is denied. 125 | In order to allow later requests, the bucket leaks at a fixed rate. 126 | 127 | ```php 128 | use BehEh\Flaps\Throttle\LeakyBucketStrategy; 129 | 130 | $flap->pushThrottlingStrategy(new LeakyBucketStrategy(60, '10m')); 131 | ``` 132 | 133 | ### Custom throttling strategy 134 | 135 | Once again, you can supply your own throttling strategy by implementing _BehEh\Flaps\ThrottlingStrategyInterface_. 136 | 137 | ## Violation handler 138 | 139 | You can handle violations either using one of the included handlers or by writing your own. 140 | 141 | ## HTTP violation handler 142 | 143 | The HTTP violation handler is the most basic violation handler, recommended for simple scripts. 144 | It simply sends the correct HTTP header (status code 429) and die()s. This is not recommended for any larger application and should be replaced by one of the more customizable handlers. 145 | 146 | ```php 147 | use BehEh\Flaps\Violation\HttpViolationHandler; 148 | 149 | $flap->setViolationHandler(new HttpViolationHandler); 150 | $flap->limit($identifier); // send "HTTP/1.1 429 Too Many Requests" and die() on violation 151 | ``` 152 | 153 | ## Passive violation handler 154 | 155 | The passive violation handler allows you to easily react to violations. 156 | `limit()` will return false if the requests violates any throttling strategy, so you are able to log the request or return a custom error page. 157 | 158 | ```php 159 | use BehEh\Flaps\Violation\PassiveViolationHandler; 160 | 161 | $flap->setViolationHandler(new PassiveViolationHandler); 162 | if (!$flap->limit($identifier)) { 163 | // violation 164 | } 165 | ``` 166 | 167 | ### Exception violation handler 168 | 169 | The exception violation handler can be used in larger frameworks. It will throw a _ThrottlingViolationException_ whenever a _ThrottlingStrategy_ is violated. 170 | You should be able to setup your exception handler to catch any _ThrottlingViolationException_. 171 | 172 | ```php 173 | use BehEh\Flaps\Violation\ExceptionViolationHandler; 174 | use BehEh\Flaps\Violation\ThrottlingViolationException; 175 | 176 | $flap->setViolationHandler(new ExceptionViolationHandler); 177 | try { 178 | $flap->limit($identifier); // throws ThrottlingViolationException on violation 179 | } 180 | catch (ThrottlingViolationException $e) { 181 | // violation 182 | } 183 | ``` 184 | 185 | ### Custom violation handler 186 | 187 | The corresponding interface for custom violation handlers is _BehEh\Flaps\ViolationHandlerInterface_. 188 | 189 | ### Default violation handler 190 | 191 | The `Flaps` object can pass a default violation handler to the flaps. 192 | 193 | ```php 194 | $flaps->setDefaultViolationHandler(new CustomViolationHandler); 195 | 196 | $flap = $flaps->__get('login'); 197 | $flap->addThrottlingStrategy(new TimeBasedThrottlingStrategy(1, '1s')); 198 | $flap->limit($identifier); // will use CustomViolationHandler 199 | ``` 200 | --------------------------------------------------------------------------------