Príklad: obmedzenie požiadaviek
GET /api/v1/quote povolí tri požiadavky za hodinu na API kľúč. Štvrtá odpovie 429 s Retry-After.
| Požiadavka | Odpoveď |
|---|---|
1. až 3. s X-Api-Key: key-a | 200, X-RateLimit-Remaining 3, 2, 1 |
4. s key-a | 429 Slow down, X-RateLimit-Remaining: 0 a Retry-After |
1. s key-b | 200, 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
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().
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
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
#!/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.