0

Rate limiting & throttling — same goal, different mechanisms

Intermediate5 min read·eng-13-013
interviewsecurity

Concept

Rate limiting vs throttling — two terms often used interchangeably, but with a precise distinction.

Rate limiting: An upper bound on how many requests a client can make in a given time window. When the limit is exceeded, requests are rejected (usually with 429 Too Many Requests). It's about PREVENTION — hard limit enforcement.

Throttling: Slowing down or delaying requests rather than rejecting them. When a client exceeds a threshold, requests are queued or artificially slowed. Netflix throttles video bitrate under congestion instead of rejecting the stream.

In practice: Most people (including Laravel's docs) use "rate limiting" to mean both. The RFC 6585 status code "429 Too Many Requests" is for rate limiting.

Rate limiting strategies:

  • Fixed window: Count requests in a fixed time window (every minute from :00 to :59). Easy to implement. Susceptible to burst at window boundaries.
  • Sliding window: Count requests in the last N seconds from now. Smoother, prevents boundary bursts.
  • Token bucket: A bucket holds N tokens. Each request consumes a token. Tokens refill at a fixed rate. Allows bursts up to bucket size.
  • Leaky bucket: Requests enter a queue (the bucket), processed at a fixed rate (the leak). Excess requests overflow (rejected).

Rate limiting keys:

  • By IP: Good for unauthenticated APIs and preventing abuse.
  • By user ID: Better for authenticated APIs.
  • By API key: For third-party developers.
  • By route: Different limits for different endpoints (stricter for /payments, looser for /products).

Response headers (standard per RFC 6585 and convention):

  • X-RateLimit-Limit: Max requests allowed.
  • X-RateLimit-Remaining: Requests left in the current window.
  • X-RateLimit-Reset: Timestamp when the window resets.
  • Retry-After: (on 429) Seconds to wait before retrying.

Code Example

php
<?php
// LARAVEL THROTTLE MIDDLEWARE — fixed window rate limiting
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/api/users', [UserController::class, 'index']);
    // 60 requests per 1 minute per client (by IP or user)
    // Returns 429 when exceeded
});

// Named rate limiters (Laravel 8+)
// In RouteServiceProvider (or AppServiceProvider):
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by(
        $request->user()?->id ?: $request->ip() // per user if auth, else per IP
    );
});

RateLimiter::for('uploads', function (Request $request) {
    return [
        Limit::perMinute(10)->by($request->user()->id),         // 10 per minute
        Limit::perDay(100)->by($request->user()->id),           // 100 per day (stacked)
    ];
});

Route::middleware('throttle:api')->group(fn() => Route::post('/search', fn() => []));
Route::middleware('throttle:uploads')->post('/upload', fn() => []);

// TOKEN BUCKET — Redis implementation
class TokenBucketLimiter
{
    public function __construct(private \Illuminate\Support\Facades\Redis $redis) {}

    public function attempt(string $key, int $maxTokens, float $refillRate): bool
    {
        $lua = <<<LUA
            local key        = KEYS[1]
            local maxTokens  = tonumber(ARGV[1])
            local refillRate = tonumber(ARGV[2])   -- tokens per second
            local now        = tonumber(ARGV[3])

            local tokens    = tonumber(redis.call('hget', key, 'tokens') or maxTokens)
            local lastRefill = tonumber(redis.call('hget', key, 'last') or now)
            local elapsed   = now - lastRefill
            local newTokens = math.min(maxTokens, tokens + elapsed * refillRate)

            if newTokens < 1 then return 0 end

            redis.call('hset', key, 'tokens', newTokens - 1, 'last', now)
            redis.call('expire', key, 3600)
            return 1
        LUA;

        return (bool) \Redis::eval($lua, 1, "rate:{$key}", $maxTokens, $refillRate, time());
    }
}

// Custom rate limit headers
class RateLimitMiddleware
{
    public function handle(Request $request, \Closure $next): mixed
    {
        $key       = 'rate:' . ($request->user()?->id ?: $request->ip());
        $limit     = 60;
        $remaining = RateLimiter::remaining($key, $limit);

        if ($remaining <= 0) {
            $retryAfter = RateLimiter::availableIn($key);
            return response()->json(['error' => 'Too many requests'], 429)
                ->header('Retry-After',          $retryAfter)
                ->header('X-RateLimit-Limit',    $limit)
                ->header('X-RateLimit-Remaining', 0)
                ->header('X-RateLimit-Reset',    now()->addSeconds($retryAfter)->timestamp);
        }

        RateLimiter::hit($key, 60); // record hit, window = 60 seconds
        return $next($request)
            ->header('X-RateLimit-Limit',    $limit)
            ->header('X-RateLimit-Remaining', $remaining - 1);
    }
}