Bij het bouwen van API's loop ik regelmatig tegen het probleem aan dat traditionele rate limiting te rigide is. Een klant die precies op het einde van een minuut 100 requests doet, kan direct daarna opnieuw 100 requests afvuren zonder dat het systeem dit tegenhoudt. Dit zorgt voor onvoorspelbare piekbelasting op de server en een slechte ervaring voor andere gebruikers. Daarom ben ik overgestapt op sliding window rate limiting met Redis, wat een veel gelijkmatigere verdeling van API-calls mogelijk maakt.
Fixed window versus sliding window
De meeste rate limiting implementaties werken met vaste tijdsblokken. Als je 100 requests per minuut toestaat, reset de teller elke minuut op precies dezelfde tijd. Dit betekent dat een gebruiker theoretisch 200 requests in twee seconden kan doen als hij slim timet. In mijn Laravel applicaties merk ik dat dit patroon tot problemen leidt, vooral bij populaire endpoints die veel traffic krijgen.
Sliding window rate limiting lost dit op door niet te werken met vaste blokken, maar met een rollend venster. Als een gebruiker op 14:30:15 een request doet, kijkt het systeem naar alle requests die hij heeft gedaan sinds 14:29:15. Elke nieuwe request verschuift dit venster een seconde verder. Hierdoor krijg je een veel consistentere belasting verdeling.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Redis;
use Carbon\Carbon;
class SlidingWindowRateLimiter
{
private $redis;
private $windowSizeSeconds;
private $maxRequests;
public function __construct($windowSizeSeconds = 60, $maxRequests = 100)
{
$this->redis = Redis::connection();
$this->windowSizeSeconds = $windowSizeSeconds;
$this->maxRequests = $maxRequests;
}
public function isAllowed($identifier)
{
$key = "rate_limit:sliding:{$identifier}";
$now = Carbon::now()->getTimestamp();
$windowStart = $now - $this->windowSizeSeconds;
// Remove expired entries
$this->redis->zremrangebyscore($key, 0, $windowStart);
// Count current requests in window
$currentCount = $this->redis->zcard($key);
if ($currentCount >= $this->maxRequests) {
return false;
}
// Add current request with microsecond precision
$this->redis->zadd($key, $now + (microtime(true) - floor(microtime(true))), uniqid());
$this->redis->expire($key, $this->windowSizeSeconds + 1);
return true;
}
}
Redis Sorted Sets zijn perfect voor dit doel omdat ze automatisch gesorteerd blijven op timestamp. Met zremrangebyscore verwijder je alle oude entries in één operatie, en zcard telt snel hoeveel requests er nog in het huidige window zitten. Het toevoegen van microtime zorgt ervoor dat gelijktijdige requests een unieke score krijgen.
Dynamische limieten per gebruikerstype
Wat mij echt helpt in productie is het kunnen instellen van verschillende limieten per gebruiker of API key. Premium gebruikers krijgen meer requests, interne services hebben vrijwel geen limiet, en free tier gebruikers krijgen een conservatieve limiet. Dit doe ik door de rate limiter uit te breiden met een configureerbare limiet functie.
public function isAllowedWithDynamicLimit($identifier, $userTier = 'free')
{
$limits = [
'free' => ['window' => 60, 'max' => 50],
'premium' => ['window' => 60, 'max' => 500],
'enterprise' => ['window' => 60, 'max' => 2000],
'internal' => ['window' => 60, 'max' => 10000]
];
$config = $limits[$userTier] ?? $limits['free'];
$key = "rate_limit:sliding:{$userTier}:{$identifier}";
$now = Carbon::now()->getTimestamp();
$windowStart = $now - $config['window'];
$this->redis->zremrangebyscore($key, 0, $windowStart);
$currentCount = $this->redis->zcard($key);
if ($currentCount >= $config['max']) {
// Return how long to wait
$oldestRequest = $this->redis->zrange($key, 0, 0, 'WITHSCORES')[0] ?? null;
$resetTime = $oldestRequest ? ($oldestRequest[1] + $config['window']) - $now : 0;
return [
'allowed' => false,
'reset_in' => max(0, $resetTime),
'remaining' => 0,
'limit' => $config['max']
];
}
$this->redis->zadd($key, $now + (microtime(true) - floor(microtime(true))), uniqid());
$this->redis->expire($key, $config['window'] + 1);
return [
'allowed' => true,
'remaining' => $config['max'] - $currentCount - 1,
'limit' => $config['max'],
'reset_in' => $config['window']
];
}
Door een array terug te geven in plaats van alleen een boolean, kan ik in de API response headers toevoegen die de client helpen. X-RateLimit-Remaining en X-RateLimit-Reset zijn standaard headers die veel API clients verwachten. Dit maakt debugging veel makkelijker en geeft developers die jouw API integreren meer inzicht in hun quota.
Middleware implementatie in Laravel
Om dit praktisch te maken bind ik de rate limiter aan Laravel's middleware systeem. Dit zorgt ervoor dat ik rate limiting kan toepassen op specifieke routes of route groepen zonder code duplicatie. De middleware haalt automatisch de juiste identifier en user tier op uit de request.
<?php
namespace App\Http\Middleware;
use App\Services\SlidingWindowRateLimiter;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SlidingWindowRateLimit
{
private $rateLimiter;
public function __construct(SlidingWindowRateLimiter $rateLimiter)
{
$this->rateLimiter = $rateLimiter;
}
public function handle(Request $request, Closure $next, $maxRequests = 100, $windowSeconds = 60)
{
$identifier = $this->getIdentifier($request);
$userTier = $this->getUserTier($request);
$result = $this->rateLimiter->isAllowedWithDynamicLimit($identifier, $userTier);
if (!$result['allowed']) {
return response()->json([
'error' => 'Rate limit exceeded',
'retry_after' => $result['reset_in']
], 429)->withHeaders([
'X-RateLimit-Limit' => $result['limit'],
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => now()->addSeconds($result['reset_in'])->timestamp,
'Retry-After' => $result['reset_in']
]);
}
return $next($request)->withHeaders([
'X-RateLimit-Limit' => $result['limit'],
'X-RateLimit-Remaining' => $result['remaining'],
'X-RateLimit-Reset' => now()->addSeconds($result['reset_in'])->timestamp
]);
}
private function getIdentifier(Request $request)
{
// Try API key first, then user ID, then IP
if ($apiKey = $request->header('X-API-Key')) {
return "api_key:{$apiKey}";
}
if ($user = $request->user()) {
return "user:{$user->id}";
}
return "ip:{$request->ip()}";
}
private function getUserTier(Request $request)
{
if ($request->header('X-API-Key')) {
$apiKey = \App\Models\ApiKey::where('key', $request->header('X-API-Key'))->first();
return $apiKey ? $apiKey->tier : 'free';
}
if ($user = $request->user()) {
return $user->subscription_tier ?? 'free';
}
return 'free';
}
}
In routes/api.php kan ik deze middleware nu flexibel inzetten op verschillende eindpunten. Zware operations zoals data exports krijgen strengere limieten, terwijl lichte read operations meer ruimte krijgen.
Route::middleware(['auth:sanctum', 'sliding.rate.limit'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
});
Route::middleware(['auth:sanctum', 'sliding.rate.limit:10,300'])->group(function () {
Route::post('/reports/export', [ReportController::class, 'export']);
Route::post('/data/import', [DataController::class, 'import']);
});
De parameters 10,300 betekenen maximaal 10 requests per 300 seconden voor die specifieke route groep. Dit geeft me granulaire controle over welke endpoints hoeveel belasting mogen veroorzaken.
Monitoring en alerting
Wat ik in productie heb geleerd is dat rate limiting alleen werkt als je ook monitort hoe het gebruikt wordt. Door Redis keys te analyseren kan ik patronen ontdekken van gebruikers die consistent tegen limieten aanlopen, of endpoints die onverwacht veel traffic krijgen. Dit helpt bij het aanpassen van limieten en het identificeren van potentiële abuse.
public function getRateLimitStats($identifier)
{
$tiers = ['free', 'premium', 'enterprise', 'internal'];
$stats = [];
foreach ($tiers as $tier) {
$key = "rate_limit:sliding:{$tier}:{$identifier}";
$now = Carbon::now()->getTimestamp();
$windowStart = $now - 60; // Check last minute
$this->redis->zremrangebyscore($key, 0, $windowStart);
$currentCount = $this->redis->zcard($key);
if ($currentCount > 0) {
$requests = $this->redis->zrange($key, 0, -1, 'WITHSCORES');
$timestamps = array_column($requests, 1);
$stats[$tier] = [
'count' => $currentCount,
'first_request' => min($timestamps),
'last_request' => max($timestamps),
'requests_per_second' => $currentCount / 60
];
}
}
return $stats;
}
Deze statistieken stuur ik naar een monitoring dashboard waar ik kan zien welke API keys of gebruikers het zwaarst belasten. Soms ontdek ik dat een integratie onbedoeld in een loop zit, of dat een populaire feature meer resources nodig heeft dan verwacht. Door proactief te monitoren kan ik problemen oplossen voordat ze impact hebben op andere gebruikers.
Sliding window rate limiting heeft mijn API's stabieler gemaakt en geeft een veel eerlijkere verdeling van resources. Het vraagt wel meer van je Redis infrastructuur dan simpele counters, maar de voordelen wegen daar ruim tegenop. De investering in een goede monitoring setup loont zich ook meteen terug door de inzichten die je krijgt in hoe jouw API daadwerkelijk wordt gebruikt.