Example: rate limit
GET /api/v1/quote allows three requests per hour per API key. The fourth answers 429 with Retry-After.
| Request | Response |
|---|---|
1st to 3rd with X-Api-Key: key-a | 200, X-RateLimit-Remaining 3, 2, 1 |
4th with key-a | 429 Slow down, X-RateLimit-Remaining: 0 and Retry-After |
1st with key-b | 200, its own counter, Remaining: 3 |
| a wrong key | rate limit headers, then 401 |
The rate limit
check() returns a RateLimitResponse(limit, remaining, retryAfter, errorResponse). The presenter answers 429 when remaining is 0.
<?php
declare(strict_types=1);
namespace App;
use Nette\Http\IRequest;
use Tomaj\NetteApi\RateLimit\RateLimitInterface;
use Tomaj\NetteApi\RateLimit\RateLimitResponse;
use Tomaj\NetteApi\Response\JsonApiResponse;
/**
* Fixed window: at most $limit requests per client in $window seconds.
* Counts in files to stay runnable without Redis; use Redis or APCu in production.
*/
final class FileRateLimit implements RateLimitInterface
{
public function __construct(
private IRequest $request,
private string $storageDir,
private int $limit = 3,
private int $window = 60,
) {
}
public function check(): ?RateLimitResponse
{
// runs before authorization, so the key cannot be trusted yet; it only picks the bucket
$client = $this->request->getHeader('X-Api-Key') ?? $this->request->getRemoteAddress() ?? 'unknown';
$windowStart = intdiv(time(), $this->window) * $this->window;
$file = $this->storageDir . '/rate-' . sha1($client) . '-' . $windowStart;
$handle = fopen($file, 'c+');
flock($handle, LOCK_EX);
$usedBefore = (int) stream_get_contents($handle);
if ($usedBefore < $this->limit) {
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, (string) ($usedBefore + 1));
}
flock($handle, LOCK_UN);
fclose($handle);
$retryAfter = $windowStart + $this->window - time();
// ApiPresenter answers 429 when remaining is 0, so remaining counts this request too:
// 3, 2, 1 are let through, the fourth request gets 0 and the 429 below
return new RateLimitResponse(
$this->limit,
$this->limit - min($usedBefore, $this->limit),
$retryAfter,
// optional, without it ApiPresenter sends "Too many requests. Retry after N seconds."
new JsonApiResponse(429, ['status' => 'error', 'message' => 'Slow down', 'retry_after' => $retryAfter]),
);
}
}The services
The rate limit is the fourth argument of addApi().
application:
mapping:
Api: Tomaj\NetteApi\Presenters\*Presenter
parameters:
apiKeys:
key-a: '*'
key-b: '*'
services:
router: App\RouterFactory::create
apiPresenter:
factory: Tomaj\NetteApi\Presenters\ApiPresenter
- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
- Tomaj\NetteApi\Error\DefaultErrorHandler
- Tomaj\NetteApi\Misc\IpDetector
quoteLimit:
factory: App\FileRateLimit(storageDir: %tempDir%, limit: 3, window: 3600)
autowired: false
apiDecider:
factory: Tomaj\NetteApi\ApiDecider
setup:
# the fourth argument: a RateLimitInterface checked before the authorization
- addApi(
Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'quote'),
App\Api\QuoteHandler(),
Tomaj\NetteApi\Authorization\HeaderApiKeyAuthentication('X-Api-Key', Tomaj\NetteApi\Misc\StaticTokenRepository(%apiKeys%)),
@quoteLimit
)The handler
<?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';
}
public function handle(array $params): ResponseInterface
{
return new JsonApiResponse(200, ['quote' => 'Simplicity is prerequisite for reliability.']);
}
}The test
#!/usr/bin/env bash
# End-to-end check: three requests per hour per API key, then 429 with Retry-After.
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 -f temp/rate-*
serve 8095
for remaining in 3 2 1; do
request -H 'X-Api-Key: key-a' "$BASE/api/v1/quote"
expect_status 200 "request with $remaining left"
expect_header 'X-RateLimit-Limit: 3' "limit header"
expect_header "X-RateLimit-Remaining: $remaining" "remaining header"
done
ok "three requests pass, X-RateLimit-Remaining counts down"
request -H 'X-Api-Key: key-a' "$BASE/api/v1/quote"
expect_status 429 "fourth"
expect_header 'X-RateLimit-Remaining: 0' "fourth"
expect_header 'Retry-After:' "fourth"
expect_body '"message":"Slow down"' "fourth"
ok "the fourth request answers 429 with Retry-After and the custom body"
request -H 'X-Api-Key: key-b' "$BASE/api/v1/quote"
expect_status 200 "other key"
expect_header 'X-RateLimit-Remaining: 3' "other key"
ok "another key has its own bucket"
request -H 'X-Api-Key: wrong' "$BASE/api/v1/quote"
expect_status 401 "wrong key"
expect_header 'X-RateLimit-Remaining: 3' "wrong key"
ok "a wrong key is counted too, the limit runs before the authorization"FAQ
Why is a wrong key counted?
The rate limit runs before authorization, so a client guessing keys is limited too. See ApiPresenter pipeline.
Why a file and flock()?
So the example has no dependencies. In production use Redis with INCR and EXPIRE, or your database.
What if I return no error response?
The presenter sends 429 with Too many requests. Retry after N seconds.. See Rate limiting.
Which headers does the client get?
X-RateLimit-Limit, X-RateLimit-Remaining and, at the limit, Retry-After.
Can I limit by IP?
Yes, the example falls back to the IP when there is no key. Use an IP detector that cannot be spoofed, see Authorization.