Redis & Advanced Caching

Rate Limiting with Redis

20 min Lesson 15 of 30

Rate Limiting with Redis

Rate limiting is essential for protecting your APIs and services from abuse, ensuring fair usage, and maintaining system stability. Redis is ideal for distributed rate limiting due to its atomic operations and low latency.

Why Rate Limiting?

Common Use Cases:
✓ API throttling - Limit requests per user/IP
✓ Login attempt protection - Prevent brute force attacks
✓ Resource protection - Limit expensive operations
✓ Fair usage enforcement - Ensure equitable access
✓ DDoS mitigation - Protect against traffic floods
✓ Cost control - Limit usage of paid external APIs

Fixed Window Rate Limiting

The simplest algorithm: allow N requests per fixed time window (e.g., 100 requests per minute):

Implementation:
use Illuminate\Support\Facades\Redis;

class FixedWindowRateLimiter
{
    public function attempt(string $key, int $limit, int $windowSeconds): bool
    {
        $currentWindow = floor(time() / $windowSeconds);
        $redisKey = "rate_limit:{$key}:{$currentWindow}";

        // Increment counter
        $count = Redis::incr($redisKey);

        // Set expiry on first request
        if ($count === 1) {
            Redis::expire($redisKey, $windowSeconds * 2);
        }

        return $count <= $limit;
    }

    public function getRemainingAttempts(string $key, int $limit, int $windowSeconds): int
    {
        $currentWindow = floor(time() / $windowSeconds);
        $redisKey = "rate_limit:{$key}:{$currentWindow}";

        $count = (int) Redis::get($redisKey) ?? 0;
        return max(0, $limit - $count);
    }
}

// Usage
$rateLimiter = new FixedWindowRateLimiter();
$userId = auth()->id();

if ($rateLimiter->attempt("user:{$userId}", 100, 60)) {
    // Allow request
    return $this->processApiRequest();
} else {
    return response()->json(['error' => 'Rate limit exceeded'], 429);
}
Fixed Window Problem: Allows burst traffic at window boundaries. Example: 100 requests at 00:59, then 100 more at 01:00 = 200 requests in 1 second!

Sliding Window Rate Limiting

More accurate algorithm that prevents boundary bursts by considering a rolling time window:

Sliding Window with Sorted Sets:
class SlidingWindowRateLimiter
{
    public function attempt(string $key, int $limit, int $windowSeconds): bool
    {
        $now = microtime(true);
        $windowStart = $now - $windowSeconds;
        $redisKey = "rate_limit:sliding:{$key}";

        // Remove old entries outside the window
        Redis::zremrangebyscore($redisKey, 0, $windowStart);

        // Count current entries in window
        $count = Redis::zcard($redisKey);

        if ($count < $limit) {
            // Add current timestamp
            Redis::zadd($redisKey, $now, $now);
            Redis::expire($redisKey, $windowSeconds + 1);
            return true;
        }

        return false;
    }

    public function getRemainingAttempts(string $key, int $limit, int $windowSeconds): int
    {
        $now = microtime(true);
        $windowStart = $now - $windowSeconds;
        $redisKey = "rate_limit:sliding:{$key}";

        Redis::zremrangebyscore($redisKey, 0, $windowStart);
        $count = Redis::zcard($redisKey);

        return max(0, $limit - $count);
    }

    public function getResetTime(string $key, int $windowSeconds): int
    {
        $redisKey = "rate_limit:sliding:{$key}";

        // Get oldest timestamp
        $oldest = Redis::zrange($redisKey, 0, 0, 'WITHSCORES');

        if (empty($oldest)) {
            return time();
        }

        $oldestTime = array_values($oldest)[0];
        return (int) ceil($oldestTime + $windowSeconds);
    }
}
Advantages: Prevents boundary bursts, more accurate rate limiting, smooth traffic distribution. Disadvantage: Uses more memory (stores timestamps).

Token Bucket Algorithm

Advanced algorithm that allows bursts while maintaining average rate:

Token Bucket Implementation:
class TokenBucketRateLimiter
{
    private int $capacity;      // Maximum tokens
    private float $refillRate;  // Tokens per second

    public function __construct(int $capacity, float $refillRate)
    {
        $this->capacity = $capacity;
        $this->refillRate = $refillRate;
    }

    public function attempt(string $key, int $tokens = 1): bool
    {
        $redisKey = "rate_limit:bucket:{$key}";
        $now = microtime(true);

        // Get current state
        $data = Redis::get($redisKey);

        if ($data) {
            $state = json_decode($data, true);
            $lastRefill = $state['last_refill'];
            $currentTokens = $state['tokens'];
        } else {
            $lastRefill = $now;
            $currentTokens = $this->capacity;
        }

        // Calculate tokens to add based on time elapsed
        $elapsed = $now - $lastRefill;
        $tokensToAdd = $elapsed * $this->refillRate;
        $currentTokens = min($this->capacity, $currentTokens + $tokensToAdd);

        // Check if enough tokens available
        if ($currentTokens >= $tokens) {
            $currentTokens -= $tokens;
            $lastRefill = $now;

            // Save state
            Redis::setex($redisKey, 3600, json_encode([
                'tokens' => $currentTokens,
                'last_refill' => $lastRefill
            ]));

            return true;
        }

        return false;
    }

    public function getAvailableTokens(string $key): float
    {
        $redisKey = "rate_limit:bucket:{$key}";
        $data = Redis::get($redisKey);

        if (!$data) {
            return $this->capacity;
        }

        $state = json_decode($data, true);
        $elapsed = microtime(true) - $state['last_refill'];
        $tokensToAdd = $elapsed * $this->refillRate;

        return min($this->capacity, $state['tokens'] + $tokensToAdd);
    }
}

// Usage - Allow bursts up to 100 requests, refill at 10/second
$rateLimiter = new TokenBucketRateLimiter(100, 10);

if ($rateLimiter->attempt("user:{$userId}", 1)) {
    return $this->processApiRequest();
} else {
    return response()->json(['error' => 'Rate limit exceeded'], 429);
}
Token Bucket Benefits: Allows controlled bursts (good UX), maintains average rate over time, flexible (different operations can cost different tokens).

Laravel Rate Limiting Middleware

Integrate rate limiting into your Laravel routes:

Middleware Implementation:
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class RateLimitMiddleware
{
    private SlidingWindowRateLimiter $limiter;

    public function __construct()
    {
        $this->limiter = new SlidingWindowRateLimiter();
    }

    public function handle(Request $request, Closure $next, int $limit = 60, int $window = 60)
    {
        $key = $this->resolveRequestIdentifier($request);

        if (!$this->limiter->attempt($key, $limit, $window)) {
            return response()->json([
                'error' => 'Too many requests',
                'retry_after' => $this->limiter->getResetTime($key, $window)
            ], 429)
            ->header('X-RateLimit-Limit', $limit)
            ->header('X-RateLimit-Remaining', 0)
            ->header('Retry-After', $this->limiter->getResetTime($key, $window));
        }

        $remaining = $this->limiter->getRemainingAttempts($key, $limit, $window);

        return $next($request)
            ->header('X-RateLimit-Limit', $limit)
            ->header('X-RateLimit-Remaining', $remaining);
    }

    private function resolveRequestIdentifier(Request $request): string
    {
        if ($user = $request->user()) {
            return "user:{$user->id}";
        }

        return "ip:{$request->ip()}";
    }
}

// Register in app/Http/Kernel.php
protected $routeMiddleware = [
    'throttle.custom' => \App\Http\Middleware\RateLimitMiddleware::class,
];

// Use in routes
Route::middleware(['throttle.custom:100,60'])->group(function () {
    Route::get('/api/users', [UserController::class, 'index']);
    Route::post('/api/users', [UserController::class, 'store']);
});

Distributed Rate Limiting

Ensure rate limits work across multiple application servers:

Lua Script for Atomic Operations:
class DistributedRateLimiter
{
    public function attempt(string $key, int $limit, int $window): bool
    {
        $script = <<<LUA
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local window_start = now - window

-- Remove old entries
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

-- Count current entries
local count = redis.call('ZCARD', key)

if count < limit then
    -- Add new entry
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window + 1)
    return 1
else
    return 0
end
LUA;

        $now = microtime(true);
        $result = Redis::eval($script, 1, "rate_limit:{$key}", $limit, $window, $now);

        return $result === 1;
    }
}

// This ensures atomic operations across multiple servers
Critical for Distributed Systems: Using Lua scripts ensures all rate limit operations are atomic, preventing race conditions when multiple servers check limits simultaneously.

IP-Based vs User-Based Rate Limiting

Flexible Rate Limiting Strategy:
class AdaptiveRateLimiter
{
    private array $limits = [
        'anonymous' => ['limit' => 10, 'window' => 60],
        'authenticated' => ['limit' => 100, 'window' => 60],
        'premium' => ['limit' => 1000, 'window' => 60],
    ];

    public function checkLimit(Request $request): bool
    {
        $tier = $this->getUserTier($request);
        $config = $this->limits[$tier];

        $key = $this->generateKey($request, $tier);
        $limiter = new SlidingWindowRateLimiter();

        return $limiter->attempt($key, $config['limit'], $config['window']);
    }

    private function getUserTier(Request $request): string
    {
        if (!$request->user()) {
            return 'anonymous';
        }

        if ($request->user()->isPremium()) {
            return 'premium';
        }

        return 'authenticated';
    }

    private function generateKey(Request $request, string $tier): string
    {
        if ($tier === 'anonymous') {
            return "ip:{$request->ip()}";
        }

        return "user:{$request->user()->id}";
    }
}

Rate Limit Patterns for Different Endpoints

Route-Specific Limits:
// Strict limits for expensive operations
Route::post('/api/export', [ExportController::class, 'export'])
    ->middleware('throttle.custom:5,3600'); // 5 per hour

// Moderate limits for normal API calls
Route::get('/api/products', [ProductController::class, 'index'])
    ->middleware('throttle.custom:100,60'); // 100 per minute

// Lenient limits for public data
Route::get('/api/public/categories', [CategoryController::class, 'index'])
    ->middleware('throttle.custom:300,60'); // 300 per minute

// Very strict for authentication
Route::post('/api/login', [AuthController::class, 'login'])
    ->middleware('throttle.custom:5,300'); // 5 per 5 minutes

Rate Limit Monitoring and Alerts

Track Rate Limit Violations:
class RateLimitMonitor
{
    public function recordViolation(string $key, Request $request)
    {
        $violationKey = "rate_limit:violations:{$key}";

        Redis::hincrby($violationKey, date('Y-m-d:H'), 1);
        Redis::expire($violationKey, 86400 * 7); // Keep 7 days

        // Alert if threshold exceeded
        $hourlyCount = Redis::hget($violationKey, date('Y-m-d:H'));

        if ($hourlyCount > 100) {
            $this->sendAlert($key, $hourlyCount, $request);
        }
    }

    public function getViolationStats(string $key): array
    {
        $violationKey = "rate_limit:violations:{$key}";
        return Redis::hgetall($violationKey) ?? [];
    }

    private function sendAlert(string $key, int $count, Request $request)
    {
        logger()->warning("High rate limit violations", [
            'key' => $key,
            'count' => $count,
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent()
        ]);

        // Send to monitoring service (e.g., Sentry, Slack)
    }
}

Best Practices

Rate Limiting Guidelines:
1. Choose the Right Algorithm:
   ✓ Fixed Window - Simple, low memory
   ✓ Sliding Window - Accurate, prevents bursts
   ✓ Token Bucket - Allows bursts, best UX

2. Set Appropriate Limits:
   ✓ Authentication: 5-10 per 5-15 minutes
   ✓ API reads: 100-1000 per minute
   ✓ API writes: 50-100 per minute
   ✓ Expensive ops: 5-10 per hour

3. Return Proper Headers:
   ✓ X-RateLimit-Limit (total allowed)
   ✓ X-RateLimit-Remaining (remaining)
   ✓ X-RateLimit-Reset (reset timestamp)
   ✓ Retry-After (seconds to wait)

4. Use Tiered Limits:
   ✓ Anonymous < Authenticated < Premium
   ✓ Different limits per endpoint
   ✓ Adjust based on user behavior

5. Monitor and Alert:
   ✓ Track violation patterns
   ✓ Alert on unusual spikes
   ✓ Analyze for potential attacks
   ✓ Adjust limits based on data
Practice Exercise:
  1. Implement all three rate limiting algorithms (Fixed Window, Sliding Window, Token Bucket)
  2. Create middleware that applies tiered rate limits (anonymous: 10/min, authenticated: 100/min, premium: 1000/min)
  3. Build a monitoring dashboard showing rate limit violations by hour/day
  4. Implement per-endpoint rate limits (login: 5/5min, export: 3/hour, API: 100/min)
  5. Add Lua script implementation for distributed rate limiting to prevent race conditions
  6. Create an admin endpoint to view and temporarily adjust rate limits for specific users