Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
razonyang committed Aug 16, 2019
0 parents commit 2f3e7f1
Show file tree
Hide file tree
Showing 25 changed files with 921 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# composer
composer.lock
composer.phar
vendor/

# phpunit
.phpunit.result.cache
14 changes: 14 additions & 0 deletions .scrutinizer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
build:
nodes:
analysis:
tests:
override:
- php-scrutinizer-run

checks:
php: true
tools:
php_code_coverage:
enabled: true
external_code_coverage:
timeout: 600
6 changes: 6 additions & 0 deletions .styleci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
preset: psr2

finder:
exclude:
- docs
- vendor
33 changes: 33 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
language: php

php:
- 7.2
- 7.3

# faster builds on new travis setup not using sudo
sudo: false

services:
- memcached
- redis

# cache vendor dirs
cache:
directories:
- $HOME/.composer/cache

install:
- travis_retry composer self-update && composer --version
- export PATH="$HOME/.composer/vendor/bin:$PATH"
- travis_retry composer install --prefer-dist --no-interaction

before_script:
- echo "extension = memcached.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- echo "extension = redis.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini

script:
- vendor/bin/phpunit $PHPUNIT_FLAGS --coverage-clover=coverage.clover

after_script:
- wget -c https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover coverage.clover
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
v1.0.0
------
First release.
29 changes: 29 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
BSD 3-Clause License

Copyright (c) 2019, Razon Yang
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
PHP Token Bucket
================

[![Build Status](https://travis-ci.org/razonyang/php-token-bucket.svg?branch=master)](https://travis-ci.org/razonyang/php-token-bucket)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/razonyang/php-token-bucket/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/razonyang/php-token-bucket/?branch=master)
[![Code Coverage](https://scrutinizer-ci.com/g/razonyang/php-token-bucket/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/razonyang/php-token-bucket/?branch=master)
[![Latest Stable Version](https://img.shields.io/packagist/v/razonyang/token-bucket.svg)](https://packagist.org/packages/razonyang/token-bucket)
[![Total Downloads](https://img.shields.io/packagist/dt/razonyang/token-bucket.svg)](https://packagist.org/packages/razonyang/token-bucket)
[![LICENSE](https://img.shields.io/github/license/razonyang/php-token-bucket)](LICENSE)

It is an implementation of [Token Bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm that for HTTP rate limiter.

Installation
------------

```shell
$ composer require razonyang/token-bucket
```

Integration
-----------

- [PSR-15 Rate Limiter Middleware](https://github.com/razonyang/psr-middleware-rate-limiter)
- [Yii2 Rate Limiter](https://github.com/razonyang/yii2-rate-limiter)

You can also build your own, let's take 5000 requests per hours as example:

```php
// create a token bucket manager
$capacity = 5000; // each bucket capacity, in other words, maximum number of tokens.
$rate = 0.72; // 3600/5200, how offen the token will be added to bucket,
$serializer = new \RazonYang\TokenBucket\Serializer\JsonSerializer();
$ttl = 3600; // time to live.
$prefix = 'rateLimiter:'; // prefix.

$manager = new \RazonYang\TokenBucket\Manager\RedisManager($capacity, $rate, $serializer, $redis, $ttl, $prefix);

// implements rate limiter, comsumes a token from the bucket which called $name.
$name = 'uid:route'; // the name of bucket.
$comsumed = $manager->consume($name, $remaining, $reset);

// set header
header('X-Rate-Limit-Limit: ' . $manager->getLimit());
header('X-Rate-Limit-Remaining: ' . $remaining); // remaining number of tokens.
header('X-Rate-Limit-Reset: ' . $reset);

if (!$comsumed) {
throw new \Exception('Too many requests', 429);
}

// continue handling
```

Credit
------

It was inspired from the follow documents and code:

- [Token Bucket](https://en.wikipedia.org/wiki/Token_bucket)
- [What's a good rate limiting algorithm?](https://stackoverflow.com/questions/667508/whats-a-good-rate-limiting-algorithm)
- [Yii2 Rate Limiter](https://github.com/yiisoft/yii2/blob/master/framework/filters/RateLimiter.php)
33 changes: 33 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "razonyang/token-bucket",
"description": "PHP Token Bucket",
"type": "library",
"keywords": ["token bucket", "rate limiter"],
"license": "BSD-3-Clause",
"authors": [
{
"name": "Razon Yang",
"email": "razonyang@gmail.com"
}
],
"require": {
"php": "^7.2"
},
"suggest": {
"ext-memcached": "MemcachedManager require memcached extension",
"ext-redis": "RedisManager require redis extension"
},
"require-dev": {
"phpunit/phpunit": "^8"
},
"autoload": {
"psr-4": {
"RazonYang\\TokenBucket\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"RazonYang\\TokenBucket\\Tests\\": "tests"
}
}
}
30 changes: 30 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
backupGlobals="false"
colors="true"
verbose="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true">
<php>
<ini name="error_reporting" value="-1" />
</php>

<testsuites>
<testsuite name="Application Unit Tests">
<directory>./tests</directory>
</testsuite>
</testsuites>

<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>
113 changes: 113 additions & 0 deletions src/Manager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php
namespace RazonYang\TokenBucket;

/**
* Manager is an abstract bucket manager that implements ManagerInterface.
*/
abstract class Manager implements ManagerInterface
{
/**
* @var int $capacity
*/
private $capacity;

public function getCapacity(): int
{
return $this->capacity;
}

/**
* @var float $rate
*/
private $rate;

public function getRate(): float
{
return $this->rate;
}

/**
* @var SerializerInterface $serializer
*/
private $serializer;

public function __construct(int $capacity, float $rate, SerializerInterface $serializer)
{
$this->capacity = $capacity;
$this->rate = $rate;
$this->serializer = $serializer;
}

public function laodAllowance(string $name): array
{
$value = $this->load($name);
if ($value === false) {
return [$this->capacity, 0];
}

$data = $this->serializer->unserialize($value);
if (!is_array($data) || count($data) !== 2) {
return [$this->capacity, 0];
}

return $data;
}

/**
* Loads allowance, return data if exists, otherwise false.
*
* @param string $name
*
* @return mixed|false
*/
abstract protected function load(string $name);

public function saveAllowance(string $name, int $allowance, int $timestamp)
{
$value = $this->serializer->serialize([$allowance, $timestamp]);
$this->save($name, $value);
}

/**
* Saves allowance.
*
* @param string $name
* @param mixed $value
*
* @throws \Throwable throws an exception if save fails.
*/
abstract protected function save(string $name, $value);

/**
* Consumes a token from the bucket.
*
* @param string $name bucket name.
*
* @return bool returns true on success, otherwise false.
*/
public function consume(string $name, ?int &$remaining = null, ?int &$reset = null): bool
{
list($allowance, $timestamp) = $this->laodAllowance($name);
$now = time();
$allowance += intval(($now - $timestamp) / $this->rate);
if ($allowance > $this->capacity) {
$allowance = $this->capacity;
}

if ($allowance < 1) {
$remaining = 0;
$reset = intval($this->capacity * $this->rate) - ($now - $timestamp);
return false;
}

$remaining = $allowance - 1;
$reset = intval(($this->capacity - $allowance) * $this->rate);
$this->saveAllowance($name, $remaining, $now);
return true;
}

public function getLimit(int $period): int
{
return min($this->capacity, intval($period / $this->rate));
}
}
49 changes: 49 additions & 0 deletions src/Manager/MemcachedManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
namespace RazonYang\TokenBucket\Manager;

use RazonYang\TokenBucket\Manager;
use RazonYang\TokenBucket\SerializerInterface;

class MemcachedManager extends Manager
{
/**
* @var int $ttl time to live.
*/
private $ttl = 0;

/**
* @var string $prefix key prefix.
*/
private $prefix = '';

/**
* @var \Memcached $memcached
*/
private $memcached;

public function __construct(
int $capacity,
float $rate,
SerializerInterface $serializer,
\Memcached $memcached,
int $ttl = 0,
string $prefix = ''
) {
parent::__construct($capacity, $rate, $serializer);
$this->memcached = $memcached;
$this->ttl = $ttl;
$this->prefix = $prefix;
}

protected function load(string $name)
{
return $this->memcached->get($this->prefix . $name);
}

protected function save(string $name, $data)
{
if (!$this->memcached->set($this->prefix . $name, $data, $this->ttl)) {
throw new \RuntimeException(sprintf('Unable to save allowance: #%d %s', $this->memcached->getResultCode(), $this->memcached->getResultMessage()));
}
}
}

0 comments on commit 2f3e7f1

Please sign in to comment.