Skip to content

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.

Open the diagram at full size

The interface ​

php
interface RateLimitInterface
{
    public function check(): ?RateLimitResponse;
}

check() runs first, before params and authorization. Return null for "no limit here", or a RateLimitResponse:

php
new RateLimitResponse(int $limit, int $remaining, ?int $retryAfter = null, ?IResponse $errorResponse = null)
ResultResponse
nullnothing added, the request continues
remaining > 0X-RateLimit-Limit and X-RateLimit-Remaining headers, the request continues
remaining = 0429, 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
<?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:

yaml
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.

Released under the MIT License.