Example: rate limit
Every endpoint allows 100 requests per minute per IP. GET /api/v1/quote also allows three requests per hour per API key, GET /api/v1/ping two per minute from its attribute, and GET /api/v1/status has no limit.
| Request | Response |
|---|---|
1st to 3rd /quote with X-Api-Key: key-a | 200, RateLimit: "perKey";r=2, 1, 0 |
4th /quote with key-a | 429 with Retry-After |
1st /quote with key-b | 200, its own quota, r=2 |
/quote with a wrong key | 401, counted by perIp only |
3rd /ping | 429, Retry-After: 30 |
any /status | 200, no rate limit headers |
The configuration
rateLimits defines the named policies, defaults.rateLimit applies perIp to every endpoint, quote adds perKey and status turns the limit off.
extensions:
api: Tomaj\NetteApi\DI\NetteApiExtension
parameters:
apiKeys:
key-a: '*'
key-b: '*'
services:
router: App\RouterFactory::create
api:
# where the counters are kept: cache (the default, the Nette cache in %tempDir%), memory, redis or your own storage
rateLimiting:
storage: cache
authorizations:
apiKey: Tomaj\NetteApi\Authorization\HeaderApiKeyAuthentication('X-Api-Key', Tomaj\NetteApi\Misc\StaticTokenRepository(%apiKeys%))
# named policies, shared by every endpoint that uses the name
rateLimits:
# checked before the authorization, so a client guessing keys is limited too
perIp: {algorithm: slidingWindow, limit: 100, interval: 1 minute, key: ip}
# checked after the authorization, one quota per API key
perKey: {algorithm: fixedWindow, limit: 3, interval: 1 hour, key: identity}
# the app-wide default for every endpoint
defaults:
authorization: apiKey
rateLimit: perIp
# the endpoints with #[RateLimit] on the class
discovery:
paths: [%appDir%/Api/Discovered]
endpoints:
# override: both policies count
- {method: GET, version: 1, package: quote, handler: App\Api\QuoteHandler, rateLimit: [perIp, perKey]}
# disabled: a public health check without any limit
- {method: GET, version: 1, package: status, handler: App\Api\StatusHandler, authorization: false, rateLimit: false}The handlers
<?php
declare(strict_types=1);
namespace App\Api;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
final class QuoteHandler extends BaseHandler
{
public function summary(): string
{
return 'A quote, at most three per hour per API key and 100 per minute per IP';
}
public function handle(array $params): ResponseInterface
{
return new JsonApiResponse(200, ['quote' => 'Simplicity is prerequisite for reliability.']);
}
}#[RateLimit] on a discovered handler replaces the default with an inline limit:
<?php
declare(strict_types=1);
namespace App\Api\Discovered;
use Tomaj\NetteApi\Attribute\Authorization;
use Tomaj\NetteApi\Attribute\Get;
use Tomaj\NetteApi\Attribute\RateLimit;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
/**
* An inline limit on the class instead of the default perIp: 2 requests per minute per IP, with a token bucket
*/
#[Get(1, 'ping')]
#[Authorization(false)]
#[RateLimit(limit: 2, interval: '1 minute', algorithm: 'tokenBucket', name: 'ping')]
final class PingHandler extends BaseHandler
{
public function summary(): string
{
return 'Pong, at most 2 per minute per IP';
}
public function handle(array $params): ResponseInterface
{
return new JsonApiResponse(200, ['pong' => true]);
}
}The test
#!/usr/bin/env bash
# End-to-end check: an app-wide default limit, an endpoint with two policies, an attribute limit and an endpoint without a limit.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
source ../ci/lib.sh
rm -rf temp/cache
serve 8095
avoid_minute_edge
for remaining in 2 1 0; do
request -H 'X-Api-Key: key-a' "$BASE/api/v1/quote"
expect_status 200 "request with $remaining left"
expect_header 'RateLimit-Policy: "perIp";q=100;w=60' "perIp policy"
expect_header 'RateLimit-Policy: "perKey";q=3;w=3600' "perKey policy"
expect_header "RateLimit: \"perKey\";r=$remaining;" "perKey remaining"
done
ok "GET /quote counts perIp and perKey, RateLimit counts down"
request -H 'X-Api-Key: key-a' "$BASE/api/v1/quote"
expect_status 429 "fourth"
expect_header 'RateLimit: "perKey";r=0;' "fourth"
expect_header 'Retry-After:' "fourth"
expect_body '"message":"Too many requests. Retry after' "fourth"
ok "the fourth request of a key answers 429 with Retry-After"
request -H 'X-Api-Key: key-b' "$BASE/api/v1/quote"
expect_status 200 "other key"
expect_header 'RateLimit: "perKey";r=2;' "other key"
ok "another key has its own quota"
request -H 'X-Api-Key: wrong' "$BASE/api/v1/quote"
expect_status 401 "wrong key"
expect_header 'RateLimit: "perIp";r=94;' "wrong key"
no_header 'RateLimit-Policy: "perKey"' "wrong key"
ok "a wrong key is counted by perIp before the authorization, perKey needs an identity"
for i in 1 2; do
request "$BASE/api/v1/ping"
expect_status 200 "ping $i"
expect_header 'RateLimit-Policy: "ping";q=2;w=60' "ping"
done
request "$BASE/api/v1/ping"
expect_status 429 "third ping"
expect_header 'Retry-After: ' "third ping"
ok "#[RateLimit(limit: 2, ...)] replaces the default, the third ping has to wait"
for i in 1 2 3 4 5; do
request "$BASE/api/v1/status"
expect_status 200 "status $i"
no_header 'RateLimit' "status"
done
ok "rateLimit: false turns the default off"FAQ
Why is a wrong key counted?
perIp is keyed by the IP, so it runs before the authorization and a client guessing keys is limited too. perKey is keyed by identity, so it runs after a successful authorization. See Rate limiting.
Why does the third ping wait 30 seconds?
tokenBucket with 2 per minute refills one token every 30 seconds.
Where are the counters?
In the Nette cache in temp/cache. With more servers use storage: redis, see Storages.
Which headers does the client get?
RateLimit-Policy and RateLimit for every policy that applied, and Retry-After with 429.
Can I limit by IP behind a proxy?
Yes, list the proxy in http: proxy:, otherwise all clients share its quota. See Which IP is counted?.