Rate limiting
A rate limit is the optional fourth argument of addApi(). The library defines the interface and the response; how you count requests (Redis, a database, a file) is up to you.
The interface
interface RateLimitInterface
{
public function check(): ?RateLimitResponse;
}check() runs first, before params and authorization. Return null for "no limit here", or a RateLimitResponse:
new RateLimitResponse(int $limit, int $remaining, ?int $retryAfter = null, ?IResponse $errorResponse = null)| Result | Response |
|---|---|
null | nothing added, the request continues |
remaining > 0 | X-RateLimit-Limit and X-RateLimit-Remaining headers, the request continues |
remaining = 0 | 429, the same headers plus Retry-After, and {"status":"error","message":"Too many requests. Retry after N seconds."} or your $errorResponse. A null retryAfter sends an empty Retry-After header and the message Retry after seconds. without a number |
Endpoints without a rate limit get NoRateLimit, which returns null.
Example
The authorization example counts requests per IP in a file, 3 a minute:
<?php
declare(strict_types=1);
namespace App;
use Tomaj\NetteApi\Misc\IpDetectorInterface;
use Tomaj\NetteApi\RateLimit\RateLimitInterface;
use Tomaj\NetteApi\RateLimit\RateLimitResponse;
/**
* A fixed window per client IP, counted in a file. Use Redis or a database in production.
*/
final class FileRateLimit implements RateLimitInterface
{
public function __construct(
private IpDetectorInterface $ipDetector,
private string $directory,
private int $limit = 3,
private int $window = 60,
) {
}
public function check(): ?RateLimitResponse
{
$windowStart = intdiv(time(), $this->window) * $this->window;
$file = $this->directory . '/rate-' . md5($this->ipDetector->getRequestIp()) . '-' . $windowStart;
$hits = (int) @file_get_contents($file) + 1;
file_put_contents($file, (string) $hits, LOCK_EX);
$retryAfter = $windowStart + $this->window - time();
// ApiPresenter answers 429 with Retry-After when remaining is 0,
// so the request being checked is not counted in remaining yet
$remaining = max(0, $this->limit - $hits + 1);
return new RateLimitResponse($this->limit, $remaining, $retryAfter);
}
}Register it with autowired: false. Otherwise Nette DI autowires it as the fourth argument of every addApi() call in the setup:
services:
rateLimit:
factory: App\FileRateLimit(directory: %tempDir%)
autowired: false
apiDecider:
factory: Tomaj\NetteApi\ApiDecider
setup:
- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'limited'), App\Api\WhoAmIHandler, Tomaj\NetteApi\Authorization\NoAuthorization(), @rateLimit)FAQ
Does the rate limit know which token called?
check() gets no arguments, because it runs before authorization. Inject Nette\Http\IRequest and read the header or the IP yourself.
Why do all my endpoints suddenly have a rate limit?
The rate limit service is autowired into addApi(). Set autowired: false, as shown above.
Are 429 responses logged?
No, in 3.x the presenter returns before the logger runs. See Logging.
Can I return my own 429 body?
Yes, pass it as the fourth argument of RateLimitResponse.
Which IP should I count by?
Use an IP detector that cannot be spoofed; see the warning in Authorization.