0

Design a rate limiter — token bucket in PHP

Advanced5 min read·eng-11-003
interviewsecurity

Concept

Rate limiter design — limit how many requests a user or IP can make in a time window.

Token bucket algorithm:

  • Each user has a "bucket" that holds tokens.
  • Bucket capacity = max burst (e.g., 100 tokens).
  • Tokens are added at a refill rate (e.g., 10 per second).
  • Each request consumes 1 token.
  • If the bucket is empty: reject the request (429 Too Many Requests).
  • Advantage: allows bursts up to capacity, smoothly limits sustained rate.

Sliding window counter (simpler):

  • Count requests in a rolling time window (e.g., last 60 seconds).
  • If count >= limit: reject.
  • In Redis: INCR key + EXPIRE key.
  • Problem with fixed windows: burst at window boundary (100 requests at 59s, 100 at 61s = 200 in 2s).
  • Sliding window fix: use a sorted set (ZADD) with timestamps, count members in the last 60s.

Redis INCR + EXPIRE (fixed window, simplest implementation):

text
INCR rate:user:42:2024-01-01-14:00
EXPIRE rate:user:42:2024-01-01-14:00 60

Laravel's built-in: RateLimiter facade. ThrottleRequests middleware. RateLimiter::for('api', fn($req) => Limit::perMinute(60)) in RouteServiceProvider.

Response headers (best practice): Return X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After in responses. Clients know their current limit status.

Different limits for different users: Authenticated users get higher limits than guests. Admins may have no limit. Laravel RateLimiter::for() with dynamic limits based on request/user.

Code Example

php
<?php
// Token bucket in Redis (PHP implementation)
class TokenBucketRateLimiter
{
    public function __construct(
        private readonly \Illuminate\Contracts\Cache\Repository $redis,
        private readonly int $capacity,    // max burst
        private readonly int $refillRate,  // tokens per second
    ) {}

    public function attempt(string $key): bool
    {
        $now         = microtime(true);
        $bucketKey   = "rate_bucket:{$key}";
        $lastKey     = "rate_last:{$key}";

        $tokens   = (float) ($this->redis->get($bucketKey) ?? $this->capacity);
        $lastTime = (float) ($this->redis->get($lastKey) ?? $now);

        // Refill tokens based on elapsed time
        $elapsed = $now - $lastTime;
        $tokens  = min($this->capacity, $tokens + $elapsed * $this->refillRate);

        if ($tokens < 1) {
            return false; // bucket empty — reject
        }

        $tokens--;
        $this->redis->put($bucketKey, $tokens, 3600);
        $this->redis->put($lastKey, $now, 3600);
        return true;
    }

    public function remaining(string $key): int
    {
        return (int) floor($this->redis->get("rate_bucket:{$key}") ?? $this->capacity);
    }
}

// Sliding window with Redis sorted set
class SlidingWindowRateLimiter
{
    public function __construct(
        private readonly \Illuminate\Contracts\Redis\Connection $redis,
        private readonly int $limit,
        private readonly int $windowSeconds,
    ) {}

    public function attempt(string $key): bool
    {
        $now       = microtime(true);
        $windowKey = "rate_sw:{$key}";

        $this->redis->pipeline(function ($pipe) use ($now, $windowKey) {
            $pipe->zremrangebyscore($windowKey, '-inf', $now - $this->windowSeconds); // remove old entries
            $pipe->zadd($windowKey, $now, $now);                                       // add current request
            $pipe->zcard($windowKey);                                                  // count in window
            $pipe->expire($windowKey, $this->windowSeconds + 1);
        });

        $count = $this->redis->zcard($windowKey);
        return $count <= $this->limit;
    }
}

// Laravel RateLimiter — what you use in practice
// In RouteServiceProvider::boot():
\RateLimiter::for('api', function (\Illuminate\Http\Request $request) {
    return $request->user()
        ? \Illuminate\Cache\RateLimiting\Limit::perMinute(60)->by($request->user()->id)
        : \Illuminate\Cache\RateLimiting\Limit::perMinute(10)->by($request->ip());
});

\RateLimiter::for('login', function (\Illuminate\Http\Request $request) {
    return [
        \Illuminate\Cache\RateLimiting\Limit::perMinute(5)->by($request->ip()),
        \Illuminate\Cache\RateLimiting\Limit::perMinute(10)->by($request->input('email')),
    ];
});

// In routes:
// Route::middleware(['throttle:api'])->group(fn() => ...);
// Route::middleware(['throttle:login'])->post('/login', ...);

// Response headers (Laravel adds these automatically):
// X-RateLimit-Limit: 60
// X-RateLimit-Remaining: 45
// X-RateLimit-Reset: 1704067200  (Unix timestamp when window resets)
// Retry-After: 30               (seconds until retry, only on 429)