├── src
├── Time
│ ├── TimerInterface.php
│ └── MicrotimeTimer.php
├── Policy
│ ├── LimitAlways.php
│ ├── LimitPerIp.php
│ ├── LimitCallback.php
│ └── LimitPolicyInterface.php
├── CounterInterface.php
├── Storage
│ ├── SimpleCacheStorage.php
│ ├── ApcuStorage.php
│ └── StorageInterface.php
├── CounterState.php
├── LimitRequestsMiddleware.php
└── Counter.php
├── .phpunit-watcher.yml
├── composer-require-checker.json
├── infection.json.dist
├── psalm.xml
├── rector.php
├── UPGRADE.md
├── CHANGELOG.md
├── LICENSE.md
├── .styleci.yml
├── composer.json
└── README.md
/src/Time/TimerInterface.php:
--------------------------------------------------------------------------------
1 | getMethod() . $request->getUri()->getPath()));
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Policy/LimitPerIp.php:
--------------------------------------------------------------------------------
1 | getMethod() . $request->getUri()->getPath() . $this->getIp($request)));
14 | }
15 |
16 | private function getIp(ServerRequestInterface $request): string
17 | {
18 | /** @psalm-var array{REMOTE_ADDR?: string} $server */
19 | $server = $request->getServerParams();
20 |
21 | return $server['REMOTE_ADDR'] ?? '';
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Policy/LimitCallback.php:
--------------------------------------------------------------------------------
1 | receiver)($request);
22 |
23 | if (!is_string($id) || '' === $id) {
24 | throw new \InvalidArgumentException('The id must be a non-empty-string.');
25 | }
26 |
27 | return $id;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | paths([
12 | __DIR__ . '/src',
13 | __DIR__ . '/tests',
14 | ]);
15 |
16 | // register a single rule
17 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class);
18 |
19 | // define sets of rules
20 | $rectorConfig->sets([
21 | LevelSetList::UP_TO_PHP_80,
22 | ]);
23 |
24 | $rectorConfig->skip([
25 | ClosureToArrowFunctionRector::class,
26 | ]);
27 | };
28 |
--------------------------------------------------------------------------------
/src/CounterInterface.php:
--------------------------------------------------------------------------------
1 | cache->set($key, $value, $ttl);
21 | }
22 |
23 | public function saveCompareAndSwap(string $key, float $oldValue, float $newValue, int $ttl): bool
24 | {
25 | return $this->cache->set($key, $newValue, $ttl);
26 | }
27 |
28 | public function get(string $key): ?float
29 | {
30 | /** @psalm-suppress MixedAssignment */
31 | $value = $this->cache->get($key);
32 |
33 | return (is_int($value) || is_float($value)) ? (float) $value : null;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/UPGRADE.md:
--------------------------------------------------------------------------------
1 | # Upgrading Instructions for Yii Rate Limiter Middleware
2 |
3 | This file contains the upgrade notes. These notes highlight changes that could break your
4 | application when you upgrade the package from one version to another.
5 |
6 | > **Important!** The following upgrading instructions are cumulative. That is, if you want
7 | > to upgrade from version A to version C and there is version B between A and C, you need
8 | > to following the instructions for both A and B.
9 |
10 | ## Upgrade from 1.x
11 |
12 | In order to switch from version 1 to version 2 you need to update initialization code:
13 |
14 | ```php
15 | $cache = new ArrayCache();
16 | $counter = new Counter(2, 5, $cache);
17 | $middleware = new Middleware($counter, $responseFactory);
18 | ```
19 |
20 | to
21 |
22 | ```php
23 | $storage = new SimpleCacheStorage($cache);
24 | $counter = new Counter($storage, 2, 5);
25 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory);
26 | ```
27 |
28 | Check the readme for new features introduced in version 2.
29 |
--------------------------------------------------------------------------------
/src/Policy/LimitPolicyInterface.php:
--------------------------------------------------------------------------------
1 | fixPrecisionRate;
33 |
34 | return apcu_add($key, (int) $value, $ttl);
35 | }
36 |
37 | public function saveCompareAndSwap(string $key, float $oldValue, float $newValue, int $ttl): bool
38 | {
39 | $oldValue *= $this->fixPrecisionRate;
40 | $newValue *= $this->fixPrecisionRate;
41 |
42 | return apcu_cas($key, (int) $oldValue, (int) $newValue);
43 | }
44 |
45 | public function get(string $key): ?float
46 | {
47 | /** @psalm-suppress MixedAssignment */
48 | $value = apcu_fetch($key);
49 |
50 | return (is_int($value) || is_float($value))
51 | ? (float) $value / $this->fixPrecisionRate
52 | : null;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/CounterState.php:
--------------------------------------------------------------------------------
1 | limit;
32 | }
33 |
34 | /**
35 | * @return int The number of remaining requests in the current time period.
36 | */
37 | public function getRemaining(): int
38 | {
39 | return $this->remaining;
40 | }
41 |
42 | /**
43 | * @return int Timestamp to wait until the rate limit resets.
44 | */
45 | public function getResetTime(): int
46 | {
47 | return $this->resetTime;
48 | }
49 |
50 | /**
51 | * @return bool If requests limit is reached.
52 | */
53 | public function isLimitReached(): bool
54 | {
55 | return $this->remaining === 0;
56 | }
57 |
58 | /**
59 | * @return bool If fail to store updated the rate limit data.
60 | */
61 | public function isFailStoreUpdatedData(): bool
62 | {
63 | return $this->isFailStoreUpdatedData;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/Storage/StorageInterface.php:
--------------------------------------------------------------------------------
1 | limitingPolicy = $limitingPolicy ?: new LimitPerIp();
36 | }
37 |
38 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
39 | {
40 | $state = $this->counter->hit($this->limitingPolicy->fingerprint($request));
41 |
42 | if ($state->isLimitReached()) {
43 | $response = $this->createErrorResponse();
44 | } elseif ($state->isFailStoreUpdatedData() && $this->failStoreUpdatedDataMiddleware !== null) {
45 | $response = $this->failStoreUpdatedDataMiddleware->process($request, $handler);
46 | } else {
47 | $response = $handler->handle($request);
48 | }
49 |
50 | return $this->addHeaders($response, $state);
51 | }
52 |
53 | private function createErrorResponse(): ResponseInterface
54 | {
55 | $response = $this->responseFactory->createResponse(Status::TOO_MANY_REQUESTS);
56 | $response->getBody()->write(Status::TEXTS[Status::TOO_MANY_REQUESTS]);
57 |
58 | return $response;
59 | }
60 |
61 | private function addHeaders(ResponseInterface $response, CounterState $result): ResponseInterface
62 | {
63 | return $response
64 | ->withHeader('X-Rate-Limit-Limit', (string) $result->getLimit())
65 | ->withHeader('X-Rate-Limit-Remaining', (string) $result->getRemaining())
66 | ->withHeader('X-Rate-Limit-Reset', (string) $result->getResetTime());
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Yii Rate Limiter Middleware
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/rate-limiter)
10 | [](https://packagist.org/packages/yiisoft/rate-limiter)
11 | [](https://github.com/yiisoft/rate-limiter/actions?query=workflow%3Abuild)
12 | [](https://scrutinizer-ci.com/g/yiisoft/rate-limiter/?branch=master)
13 | [](https://scrutinizer-ci.com/g/yiisoft/rate-limiter/?branch=master)
14 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/rate-limiter/master)
15 | [](https://github.com/yiisoft/rate-limiter/actions?query=workflow%3A%22static+analysis%22)
16 | [](https://shepherd.dev/github/yiisoft/rate-limiter)
17 |
18 | Rate limiter middleware helps to prevent abuse by limiting the number of requests that could be me made consequentially.
19 |
20 | For example, you may want to limit the API usage of each user to be at most 100 API calls within a period of 10 minutes.
21 | If too many requests are received from a user within the stated period of the time, a response with status code 429
22 | (meaning "Too Many Requests") should be returned.
23 |
24 | ## Requirements
25 |
26 | - PHP 8.0 or higher.
27 |
28 | ## Installation
29 |
30 | The package could be installed with [Composer](https://getcomposer.org):
31 |
32 | ```shell
33 | composer require yiisoft/rate-limiter
34 | ```
35 |
36 | ## General usage
37 |
38 | ```php
39 | use Psr\Http\Message\ServerRequestInterface;
40 | use Yiisoft\Yii\RateLimiter\LimitRequestsMiddleware;
41 | use Yiisoft\Yii\RateLimiter\Counter;
42 | use Nyholm\Psr7\Factory\Psr17Factory;
43 | use Yiisoft\Yii\RateLimiter\Policy\LimitAlways;
44 | use Yiisoft\Yii\RateLimiter\Policy\LimitPerIp;
45 | use Yiisoft\Yii\RateLimiter\Policy\LimitCallback;
46 | use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
47 | use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;
48 |
49 | /** @var StorageInterface $storage */
50 | $storage = new SimpleCacheStorage($cache);
51 |
52 | $counter = new Counter($storage, 2, 5);
53 | $responseFactory = new Psr17Factory();
54 |
55 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory); // LimitPerIp by default
56 | ```
57 |
58 | In the above 2 is the maximum number of counter increments (requests) that could be performed before increments
59 | are limited and 5 is a period to apply limit to, in seconds.
60 |
61 | The `Counter` implements [generic cell rate limit algorithm (GCRA)](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm)
62 | that ensures that after reaching the limit further increments are distributed equally.
63 |
64 | > Note: While it is sufficiently effective, it is preferred to use [Nginx](https://www.nginx.com/blog/rate-limiting-nginx/)
65 | > or another webserver capabilities for rate limiting. This package allows rate-limiting in the project with deployment
66 | > environment you cannot control such as installable CMS.
67 |
68 | ### Implementing your own limiting policy
69 |
70 | There are two ready to use limiting policies available in the package:
71 |
72 | - `LimitAlways` - to count all incoming requests.
73 | - `LimitPerIp` - to count requests from different IPs separately.
74 |
75 | These could be applied as follows:
76 |
77 | ```php
78 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitPerIp());
79 | // or
80 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitAlways());
81 | ```
82 |
83 | Easiest way to customize a policy is to use `LimitCallback`:
84 |
85 | ```php
86 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitCallback(function (ServerRequestInterface $request): string {
87 | // return user id from database if authentication id used i.e. limit guests and each authenticated user separately.
88 | }));
89 | ```
90 |
91 | Another way it to implement `Yiisoft\Yii\RateLimiter\Policy\LimitPolicyInterface` and use it in a similar way as above.
92 |
93 | ### Implementing your own counter storage
94 |
95 | There are two ready to use counter storages available in the package:
96 | - `\Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage` - stores counters in any [PSR-16](https://www.php-fig.org/psr/psr-16/) cache.
97 | - `\Yiisoft\Yii\RateLimiter\Storage\ApcuStorage` - stores counters by using the [APCu PHP extension](https://www.php.net/apcu) while taking concurrency into account.
98 |
99 | To use your own storage implement `Yiisoft\Yii\RateLimiter\Storage\StorageInterface`.
100 |
101 | ## Documentation
102 |
103 | - [Internals](docs/internals.md)
104 |
105 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that.
106 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community).
107 |
108 | ## License
109 |
110 | The Yii Rate Limiter Middleware is free software. It is released under the terms of the BSD License.
111 | Please see [`LICENSE`](./LICENSE.md) for more information.
112 |
113 | Maintained by [Yii Software](https://www.yiiframework.com/).
114 |
115 | ## Support the project
116 |
117 | [](https://opencollective.com/yiisoft)
118 |
119 | ## Follow updates
120 |
121 | [](https://www.yiiframework.com/)
122 | [](https://twitter.com/yiiframework)
123 | [](https://t.me/yii3en)
124 | [](https://www.facebook.com/groups/yiitalk)
125 | [](https://yiiframework.com/go/slack)
126 |
--------------------------------------------------------------------------------
/src/Counter.php:
--------------------------------------------------------------------------------
1 | periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND;
64 | $this->timer = $timer ?: new MicrotimeTimer();
65 | $this->incrementIntervalInMilliseconds = $this->periodInMilliseconds / $this->limit;
66 | }
67 |
68 | /**
69 | * {@inheritdoc}
70 | */
71 | public function hit(string $id): CounterState
72 | {
73 | $attempts = 0;
74 | $isFailStoreUpdatedData = false;
75 | do {
76 | // Last increment time.
77 | // In GCRA it's known as arrival time.
78 | $lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds();
79 |
80 | $lastStoredTheoreticalNextIncrementTime = $this->getLastStoredTheoreticalNextIncrementTime($id);
81 |
82 | $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
83 | $lastIncrementTimeInMilliseconds,
84 | $lastStoredTheoreticalNextIncrementTime
85 | );
86 |
87 | $remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime);
88 | $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);
89 |
90 | if ($remaining === 0) {
91 | break;
92 | }
93 |
94 | $isStored = $this->storeTheoreticalNextIncrementTime(
95 | $id,
96 | $theoreticalNextIncrementTime,
97 | $lastStoredTheoreticalNextIncrementTime
98 | );
99 | if ($isStored) {
100 | break;
101 | }
102 |
103 | $attempts++;
104 | if ($attempts >= $this->maxCasAttempts) {
105 | $isFailStoreUpdatedData = true;
106 | break;
107 | }
108 | } while (true);
109 |
110 | return new CounterState($this->limit, $remaining, $resetAfter, $isFailStoreUpdatedData);
111 | }
112 |
113 | /**
114 | * @return float Theoretical increment time that would be expected from equally spaced increments at exactly rate
115 | * limit. In GCRA it is known as TAT, theoretical arrival time.
116 | */
117 | private function calculateTheoreticalNextIncrementTime(
118 | float $lastIncrementTimeInMilliseconds,
119 | ?float $storedTheoreticalNextIncrementTime
120 | ): float {
121 | return (
122 | $storedTheoreticalNextIncrementTime === null
123 | ? $lastIncrementTimeInMilliseconds
124 | : max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime)
125 | ) + $this->incrementIntervalInMilliseconds;
126 | }
127 |
128 | /**
129 | * @return int The number of remaining requests in the current time period.
130 | */
131 | private function calculateRemaining(
132 | float $lastIncrementTimeInMilliseconds,
133 | float $theoreticalNextIncrementTime
134 | ): int {
135 | $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds;
136 |
137 | $remainingTimeInMilliseconds = round($lastIncrementTimeInMilliseconds - $incrementAllowedAt);
138 | if ($remainingTimeInMilliseconds > 0) {
139 | return (int) ($remainingTimeInMilliseconds / $this->incrementIntervalInMilliseconds);
140 | }
141 |
142 | return 0;
143 | }
144 |
145 | private function getLastStoredTheoreticalNextIncrementTime(string $id): ?float
146 | {
147 | return $this->storage->get($this->getStorageKey($id));
148 | }
149 |
150 | private function storeTheoreticalNextIncrementTime(
151 | string $id,
152 | float $theoreticalNextIncrementTime,
153 | ?float $lastStoredTheoreticalNextIncrementTime
154 | ): bool {
155 | if ($lastStoredTheoreticalNextIncrementTime !== null) {
156 | return $this->storage->saveCompareAndSwap(
157 | $this->getStorageKey($id),
158 | $lastStoredTheoreticalNextIncrementTime,
159 | $theoreticalNextIncrementTime,
160 | $this->storageTtlInSeconds
161 | );
162 | }
163 |
164 | return $this->storage->saveIfNotExists(
165 | $this->getStorageKey($id),
166 | $theoreticalNextIncrementTime,
167 | $this->storageTtlInSeconds
168 | );
169 | }
170 |
171 | /**
172 | * @return int Timestamp to wait until the rate limit resets.
173 | */
174 | private function calculateResetAfter(float $theoreticalNextIncrementTime): int
175 | {
176 | return (int) ($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND);
177 | }
178 |
179 | /**
180 | * @return string Storage key used to store the next increment time.
181 | */
182 | private function getStorageKey(string $id): string
183 | {
184 | return $this->storagePrefix . $id;
185 | }
186 | }
187 |
--------------------------------------------------------------------------------