Preskočiť na obsah

Príklad: obmedzenie požiadaviek ​

GET /api/v1/quote povolí tri požiadavky za hodinu na API kľúč. Štvrtá odpovie 429 s Retry-After.

Otvoriť diagram v plnej veľkosti
PožiadavkaOdpoveď
1. až 3. s X-Api-Key: key-a200, X-RateLimit-Remaining 3, 2, 1
4. s key-a429 Slow down, X-RateLimit-Remaining: 0 a Retry-After
1. s key-b200, vlastné počítadlo, Remaining: 3
zlý kľúčhlavičky obmedzenia požiadaviek, potom 401

Obmedzenie požiadaviek ​

check() vráti RateLimitResponse(limit, remaining, retryAfter, errorResponse). Presenter odpovie 429, keď je remaining 0.

php
<?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]),
        );
    }
}

Služby ​

Obmedzenie požiadaviek sa nastavuje štvrtým argumentom addApi().

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

Handler ​

php
<?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.']);
    }
}

Test ​

bash
#!/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"

Časté otázky ​

Prečo sa počíta aj zlý kľúč? ​

Obmedzenie požiadaviek beží pred autorizáciou, takže obmedzený je aj klient, ktorý kľúče hádá. Pozrite si Spracovanie v ApiPresenteri.

Prečo súbor a flock()? ​

Aby príklad nemal závislosti. V produkcii použite Redis s INCR a EXPIRE alebo databázu.

Čo ak nevrátim chybovú odpoveď? ​

Presenter pošle 429 s Too many requests. Retry after N seconds.. Pozrite si Obmedzenie požiadaviek.

Ktoré hlavičky klient dostane? ​

X-RateLimit-Limit, X-RateLimit-Remaining a pri limite Retry-After.

Môžem obmedzovať podľa IP? ​

Áno, príklad použije IP, keď kľúč chýba. Použite detektor IP, ktorý sa nedá podvrhnúť, pozrite si Autorizácia.

Vydané pod licenciou MIT.