Advanced Rate Limiting trong Laravel: Sliding Window, Token Bucket và Distributed Limiting

· 13 min read

Giới Thiệu

Rate limiting là kỹ thuật quan trọng để bảo vệ API khỏi abuse, DDoS attacks, và đảm bảo fair usage. Laravel cung cấp rate limiting cơ bản, nhưng cho production systems phức tạp, bạn cần hiểu và implement các thuật toán nâng cao.

Tại Sao Cần Advanced Rate Limiting?

Fixed Window (Basic) Advanced Algorithms
Có thể burst ở ranh giới window Smooth traffic distribution
Không fair với users Fair queueing
Dễ bị exploit Resistant to gaming
Single server only Distributed support

Các Thuật Toán Rate Limiting

1. Fixed Window Counter (Laravel Default)

Window 1 (00:00-01:00)  |  Window 2 (01:00-02:00)
     [████████████]     |     [████████████]
     100 requests       |     100 requests

Vấn đề: 100 requests ở 00:59 + 100 ở 01:01 = 200 requests trong 2 phút

2. Sliding Window Log

Sliding window tracks exact timestamps:
|----window (60s)----|
     ↓  ↓↓ ↓  ↓↓↓  ↓
Requests within window counted

3. Sliding Window Counter

Combines fixed windows with weighted average:

Previous window: 60 requests (40% weight at 24s into current)
Current window: 30 requests (60% weight)

Effective count = 60 * 0.4 + 30 = 54 requests

4. Token Bucket

Bucket capacity: 100 tokens
Refill rate: 10 tokens/second

[████████████████████] 100/100 tokens
Request comes → take 1 token
[███████████████████ ] 99/100 tokens
Refill → add tokens over time

5. Leaky Bucket

Requests enter bucket, processed at fixed rate:

    ↓ ↓ ↓ (incoming requests)
   [█████]  bucket
      ↓ (constant output rate)

Implementation

Sliding Window Counter

// app/Services/RateLimiting/SlidingWindowLimiter.php
<?php

namespace App\Services\RateLimiting;

use Illuminate\Support\Facades\Redis;

class SlidingWindowLimiter
{
    public function __construct(
        private string $prefix = 'rate_limit:sliding:'
    ) {}
    
    /**
     * Check if request is allowed using sliding window counter
     */
    public function attempt(
        string $key,
        int $maxRequests,
        int $windowSeconds
    ): RateLimitResult {
        $now = microtime(true);
        $currentWindow = floor($now / $windowSeconds);
        $previousWindow = $currentWindow - 1;
        
        $currentKey = $this->prefix . $key . ':' . $currentWindow;
        $previousKey = $this->prefix . $key . ':' . $previousWindow;
        
        // Get counts from Redis
        $pipe = Redis::pipeline();
        $pipe->get($currentKey);
        $pipe->get($previousKey);
        $results = $pipe->execute();
        
        $currentCount = (int) ($results[0] ?? 0);
        $previousCount = (int) ($results[1] ?? 0);
        
        // Calculate weighted count
        $windowPosition = $now - ($currentWindow * $windowSeconds);
        $previousWeight = 1 - ($windowPosition / $windowSeconds);
        
        $effectiveCount = ($previousCount * $previousWeight) + $currentCount;
        
        if ($effectiveCount >= $maxRequests) {
            $retryAfter = $windowSeconds - $windowPosition;
            
            return new RateLimitResult(
                allowed: false,
                remaining: 0,
                retryAfter: (int) ceil($retryAfter),
                limit: $maxRequests
            );
        }
        
        // Increment current window counter
        $pipe = Redis::pipeline();
        $pipe->incr($currentKey);
        $pipe->expire($currentKey, $windowSeconds * 2);
        $pipe->execute();
        
        return new RateLimitResult(
            allowed: true,
            remaining: (int) floor($maxRequests - $effectiveCount - 1),
            retryAfter: 0,
            limit: $maxRequests
        );
    }
    
    /**
     * Get current rate limit status without incrementing
     */
    public function status(string $key, int $maxRequests, int $windowSeconds): RateLimitResult
    {
        $now = microtime(true);
        $currentWindow = floor($now / $windowSeconds);
        $previousWindow = $currentWindow - 1;
        
        $currentKey = $this->prefix . $key . ':' . $currentWindow;
        $previousKey = $this->prefix . $key . ':' . $previousWindow;
        
        $pipe = Redis::pipeline();
        $pipe->get($currentKey);
        $pipe->get($previousKey);
        $results = $pipe->execute();
        
        $currentCount = (int) ($results[0] ?? 0);
        $previousCount = (int) ($results[1] ?? 0);
        
        $windowPosition = $now - ($currentWindow * $windowSeconds);
        $previousWeight = 1 - ($windowPosition / $windowSeconds);
        
        $effectiveCount = ($previousCount * $previousWeight) + $currentCount;
        
        return new RateLimitResult(
            allowed: $effectiveCount < $maxRequests,
            remaining: max(0, (int) floor($maxRequests - $effectiveCount)),
            retryAfter: $effectiveCount >= $maxRequests 
                ? (int) ceil($windowSeconds - $windowPosition) 
                : 0,
            limit: $maxRequests
        );
    }
}

Token Bucket Algorithm

// app/Services/RateLimiting/TokenBucketLimiter.php
<?php

namespace App\Services\RateLimiting;

use Illuminate\Support\Facades\Redis;

class TokenBucketLimiter
{
    public function __construct(
        private string $prefix = 'rate_limit:bucket:'
    ) {}
    
    /**
     * Attempt to consume tokens from bucket
     */
    public function attempt(
        string $key,
        int $bucketSize,
        int $refillRate, // tokens per second
        int $tokensToConsume = 1
    ): RateLimitResult {
        $script = <<<'LUA'
            local key = KEYS[1]
            local bucket_size = tonumber(ARGV[1])
            local refill_rate = tonumber(ARGV[2])
            local tokens_to_consume = tonumber(ARGV[3])
            local now = tonumber(ARGV[4])
            
            -- Get current state
            local bucket = redis.call('hmget', key, 'tokens', 'last_refill')
            local tokens = tonumber(bucket[1]) or bucket_size
            local last_refill = tonumber(bucket[2]) or now
            
            -- Calculate tokens to add based on time passed
            local time_passed = now - last_refill
            local tokens_to_add = time_passed * refill_rate
            tokens = math.min(bucket_size, tokens + tokens_to_add)
            
            -- Check if we have enough tokens
            if tokens < tokens_to_consume then
                -- Calculate wait time
                local tokens_needed = tokens_to_consume - tokens
                local wait_time = tokens_needed / refill_rate
                return {0, tokens, wait_time}
            end
            
            -- Consume tokens
            tokens = tokens - tokens_to_consume
            
            -- Update bucket
            redis.call('hmset', key, 'tokens', tokens, 'last_refill', now)
            redis.call('expire', key, bucket_size / refill_rate * 2)
            
            return {1, tokens, 0}
        LUA;
        
        $result = Redis::eval(
            $script,
            1,
            $this->prefix . $key,
            $bucketSize,
            $refillRate,
            $tokensToConsume,
            microtime(true)
        );
        
        return new RateLimitResult(
            allowed: (bool) $result[0],
            remaining: (int) floor($result[1]),
            retryAfter: (int) ceil($result[2]),
            limit: $bucketSize
        );
    }
    
    /**
     * Get current bucket status
     */
    public function getTokens(string $key, int $bucketSize, int $refillRate): int
    {
        $data = Redis::hmget($this->prefix . $key, ['tokens', 'last_refill']);
        
        $tokens = (float) ($data['tokens'] ?? $bucketSize);
        $lastRefill = (float) ($data['last_refill'] ?? microtime(true));
        
        $timePassed = microtime(true) - $lastRefill;
        $tokensToAdd = $timePassed * $refillRate;
        
        return (int) min($bucketSize, $tokens + $tokensToAdd);
    }
}

Leaky Bucket Algorithm

// app/Services/RateLimiting/LeakyBucketLimiter.php
<?php

namespace App\Services\RateLimiting;

use Illuminate\Support\Facades\Redis;

class LeakyBucketLimiter
{
    public function __construct(
        private string $prefix = 'rate_limit:leaky:'
    ) {}
    
    /**
     * Attempt to add request to leaky bucket
     */
    public function attempt(
        string $key,
        int $bucketSize,
        int $leakRate // requests per second
    ): RateLimitResult {
        $script = <<<'LUA'
            local key = KEYS[1]
            local bucket_size = tonumber(ARGV[1])
            local leak_rate = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            
            -- Get current state
            local bucket = redis.call('hmget', key, 'water', 'last_leak')
            local water = tonumber(bucket[1]) or 0
            local last_leak = tonumber(bucket[2]) or now
            
            -- Leak water based on time passed
            local time_passed = now - last_leak
            local water_to_leak = time_passed * leak_rate
            water = math.max(0, water - water_to_leak)
            
            -- Check if bucket has room
            if water >= bucket_size then
                -- Calculate wait time
                local wait_time = (water - bucket_size + 1) / leak_rate
                return {0, bucket_size - water, wait_time}
            end
            
            -- Add water (request) to bucket
            water = water + 1
            
            -- Update bucket
            redis.call('hmset', key, 'water', water, 'last_leak', now)
            redis.call('expire', key, bucket_size / leak_rate * 2)
            
            return {1, bucket_size - water, 0}
        LUA;
        
        $result = Redis::eval(
            $script,
            1,
            $this->prefix . $key,
            $bucketSize,
            $leakRate,
            microtime(true)
        );
        
        return new RateLimitResult(
            allowed: (bool) $result[0],
            remaining: max(0, (int) $result[1]),
            retryAfter: (int) ceil($result[2]),
            limit: $bucketSize
        );
    }
}

Rate Limit Result DTO

// app/Services/RateLimiting/RateLimitResult.php
<?php

namespace App\Services\RateLimiting;

readonly class RateLimitResult
{
    public function __construct(
        public bool $allowed,
        public int $remaining,
        public int $retryAfter,
        public int $limit
    ) {}
    
    public function headers(): array
    {
        $headers = [
            'X-RateLimit-Limit' => $this->limit,
            'X-RateLimit-Remaining' => $this->remaining,
        ];
        
        if (!$this->allowed) {
            $headers['Retry-After'] = $this->retryAfter;
            $headers['X-RateLimit-Reset'] = time() + $this->retryAfter;
        }
        
        return $headers;
    }
}

Middleware Integration

Flexible Rate Limit Middleware

// app/Http/Middleware/AdvancedRateLimit.php
<?php

namespace App\Http\Middleware;

use App\Services\RateLimiting\SlidingWindowLimiter;
use App\Services\RateLimiting\TokenBucketLimiter;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AdvancedRateLimit
{
    public function __construct(
        private SlidingWindowLimiter $slidingWindow,
        private TokenBucketLimiter $tokenBucket
    ) {}
    
    public function handle(Request $request, Closure $next, string $limiterConfig): Response
    {
        $config = $this->parseConfig($limiterConfig);
        $key = $this->resolveKey($request, $config);
        
        $result = match ($config['algorithm']) {
            'sliding_window' => $this->slidingWindow->attempt(
                $key,
                $config['max_requests'],
                $config['window_seconds']
            ),
            'token_bucket' => $this->tokenBucket->attempt(
                $key,
                $config['bucket_size'],
                $config['refill_rate']
            ),
            default => throw new \InvalidArgumentException("Unknown algorithm: {$config['algorithm']}")
        };
        
        if (!$result->allowed) {
            return response()->json([
                'error' => 'Too Many Requests',
                'message' => 'Rate limit exceeded. Please try again later.',
                'retry_after' => $result->retryAfter,
            ], 429, $result->headers());
        }
        
        $response = $next($request);
        
        // Add rate limit headers to successful response
        foreach ($result->headers() as $header => $value) {
            $response->headers->set($header, $value);
        }
        
        return $response;
    }
    
    private function parseConfig(string $config): array
    {
        // Format: algorithm:param1,param2|key_type
        // Example: sliding_window:100,60|user
        // Example: token_bucket:100,10|ip
        
        [$limiter, $keyType] = explode('|', $config) + [null, 'user'];
        [$algorithm, $params] = explode(':', $limiter);
        $params = array_map('intval', explode(',', $params));
        
        return match ($algorithm) {
            'sliding_window' => [
                'algorithm' => 'sliding_window',
                'max_requests' => $params[0],
                'window_seconds' => $params[1],
                'key_type' => $keyType,
            ],
            'token_bucket' => [
                'algorithm' => 'token_bucket',
                'bucket_size' => $params[0],
                'refill_rate' => $params[1],
                'key_type' => $keyType,
            ],
            default => throw new \InvalidArgumentException("Unknown algorithm: {$algorithm}")
        };
    }
    
    private function resolveKey(Request $request, array $config): string
    {
        $prefix = $request->route()->getName() ?? $request->path();
        
        return match ($config['key_type']) {
            'user' => $prefix . ':user:' . ($request->user()?->id ?? 'guest'),
            'ip' => $prefix . ':ip:' . $request->ip(),
            'api_key' => $prefix . ':key:' . $request->header('X-API-Key', 'none'),
            default => $prefix . ':' . $config['key_type']
        };
    }
}

Route Configuration

// routes/api.php
use App\Http\Middleware\AdvancedRateLimit;

// Sliding window: 100 requests per 60 seconds, keyed by user
Route::middleware('advanced.rate:sliding_window:100,60|user')->group(function () {
    Route::get('/users', [UserController::class, 'index']);
    Route::get('/posts', [PostController::class, 'index']);
});

// Token bucket: 100 tokens, refill 10/second, keyed by IP
Route::middleware('advanced.rate:token_bucket:100,10|ip')->group(function () {
    Route::post('/webhooks', [WebhookController::class, 'handle']);
});

// Stricter limits for auth endpoints
Route::middleware('advanced.rate:sliding_window:5,60|ip')->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::post('/register', [AuthController::class, 'register']);
});

Distributed Rate Limiting

Redis Cluster Support

// app/Services/RateLimiting/DistributedRateLimiter.php
<?php

namespace App\Services\RateLimiting;

use Illuminate\Support\Facades\Redis;

class DistributedRateLimiter
{
    private const LOCK_TTL = 5; // seconds
    
    public function __construct(
        private string $prefix = 'rate_limit:distributed:'
    ) {}
    
    /**
     * Distributed sliding window with Redis cluster support
     */
    public function attempt(
        string $key,
        int $maxRequests,
        int $windowSeconds
    ): RateLimitResult {
        // Use Lua script for atomic operation
        $script = <<<'LUA'
            local key = KEYS[1]
            local max_requests = tonumber(ARGV[1])
            local window_seconds = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            
            -- Remove old entries
            local window_start = now - window_seconds
            redis.call('zremrangebyscore', key, '-inf', window_start)
            
            -- Count current requests
            local count = redis.call('zcard', key)
            
            if count >= max_requests then
                -- Get oldest entry to calculate retry time
                local oldest = redis.call('zrange', key, 0, 0, 'WITHSCORES')
                local retry_after = 0
                if #oldest > 0 then
                    retry_after = oldest[2] + window_seconds - now
                end
                return {0, max_requests - count, retry_after}
            end
            
            -- Add current request
            redis.call('zadd', key, now, now .. ':' .. math.random())
            redis.call('expire', key, window_seconds)
            
            return {1, max_requests - count - 1, 0}
        LUA;
        
        $result = Redis::eval(
            $script,
            1,
            $this->prefix . $key,
            $maxRequests,
            $windowSeconds,
            microtime(true)
        );
        
        return new RateLimitResult(
            allowed: (bool) $result[0],
            remaining: max(0, (int) $result[1]),
            retryAfter: max(0, (int) ceil($result[2])),
            limit: $maxRequests
        );
    }
    
    /**
     * Reset rate limit for a key
     */
    public function reset(string $key): void
    {
        Redis::del($this->prefix . $key);
    }
    
    /**
     * Get current count for a key
     */
    public function count(string $key, int $windowSeconds): int
    {
        $windowStart = microtime(true) - $windowSeconds;
        
        return Redis::zcount(
            $this->prefix . $key,
            $windowStart,
            '+inf'
        );
    }
}

Tiered Rate Limiting

Subscription-Based Limits

// app/Services/RateLimiting/TieredRateLimiter.php
<?php

namespace App\Services\RateLimiting;

use App\Models\User;

class TieredRateLimiter
{
    private array $tiers = [
        'free' => [
            'requests_per_minute' => 20,
            'requests_per_hour' => 100,
            'requests_per_day' => 1000,
            'burst_size' => 10,
        ],
        'basic' => [
            'requests_per_minute' => 60,
            'requests_per_hour' => 1000,
            'requests_per_day' => 10000,
            'burst_size' => 30,
        ],
        'pro' => [
            'requests_per_minute' => 300,
            'requests_per_hour' => 5000,
            'requests_per_day' => 50000,
            'burst_size' => 100,
        ],
        'enterprise' => [
            'requests_per_minute' => 1000,
            'requests_per_hour' => 30000,
            'requests_per_day' => 500000,
            'burst_size' => 500,
        ],
    ];
    
    public function __construct(
        private SlidingWindowLimiter $slidingWindow,
        private TokenBucketLimiter $tokenBucket
    ) {}
    
    public function attempt(User $user, string $endpoint): RateLimitResult
    {
        $tier = $this->tiers[$user->subscription_tier] ?? $this->tiers['free'];
        $key = "user:{$user->id}:{$endpoint}";
        
        // Check all limits
        $results = [];
        
        // Per-minute limit (sliding window)
        $results[] = $this->slidingWindow->attempt(
            $key . ':minute',
            $tier['requests_per_minute'],
            60
        );
        
        // Per-hour limit
        $results[] = $this->slidingWindow->attempt(
            $key . ':hour',
            $tier['requests_per_hour'],
            3600
        );
        
        // Per-day limit
        $results[] = $this->slidingWindow->attempt(
            $key . ':day',
            $tier['requests_per_day'],
            86400
        );
        
        // Burst protection (token bucket)
        $results[] = $this->tokenBucket->attempt(
            $key . ':burst',
            $tier['burst_size'],
            $tier['burst_size'] / 10 // refill in 10 seconds
        );
        
        // Find the most restrictive result
        foreach ($results as $result) {
            if (!$result->allowed) {
                return $result;
            }
        }
        
        // All limits passed, return the one with lowest remaining
        return collect($results)->sortBy('remaining')->first();
    }
    
    public function getLimitsForUser(User $user): array
    {
        return $this->tiers[$user->subscription_tier] ?? $this->tiers['free'];
    }
}

Cost-Based Rate Limiting

Different Costs for Different Endpoints

// app/Services/RateLimiting/CostBasedLimiter.php
<?php

namespace App\Services\RateLimiting;

class CostBasedLimiter
{
    private array $endpointCosts = [
        'GET /api/users' => 1,
        'GET /api/users/*' => 1,
        'POST /api/users' => 5,
        'GET /api/reports' => 10,
        'POST /api/reports/generate' => 50,
        'POST /api/exports' => 100,
        'GET /api/search' => 5,
    ];
    
    public function __construct(
        private TokenBucketLimiter $tokenBucket
    ) {}
    
    public function attempt(string $endpoint, string $method, string $userKey): RateLimitResult
    {
        $cost = $this->getCost($method, $endpoint);
        
        // User has 1000 tokens per hour, refills at ~0.28 tokens/second
        return $this->tokenBucket->attempt(
            "cost:{$userKey}",
            bucketSize: 1000,
            refillRate: 1000 / 3600, // ~0.28 tokens/second
            tokensToConsume: $cost
        );
    }
    
    private function getCost(string $method, string $endpoint): int
    {
        $key = "{$method} {$endpoint}";
        
        // Check exact match
        if (isset($this->endpointCosts[$key])) {
            return $this->endpointCosts[$key];
        }
        
        // Check wildcard matches
        foreach ($this->endpointCosts as $pattern => $cost) {
            $regex = str_replace(['*', '/'], ['[^/]+', '\/'], $pattern);
            if (preg_match("/^{$regex}$/", $key)) {
                return $cost;
            }
        }
        
        return 1; // default cost
    }
}

Monitoring & Analytics

Rate Limit Metrics

// app/Services/RateLimiting/RateLimitMetrics.php
<?php

namespace App\Services\RateLimiting;

use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Log;

class RateLimitMetrics
{
    public function record(string $key, RateLimitResult $result): void
    {
        $metrics = [
            'key' => $key,
            'allowed' => $result->allowed,
            'remaining' => $result->remaining,
            'timestamp' => now()->toIso8601String(),
        ];
        
        // Record to Redis for real-time monitoring
        Redis::lpush('rate_limit:metrics', json_encode($metrics));
        Redis::ltrim('rate_limit:metrics', 0, 9999);
        
        // Record rate limit hits for analytics
        if (!$result->allowed) {
            $hourKey = 'rate_limit:hits:' . now()->format('Y-m-d:H');
            Redis::hincrby($hourKey, $key, 1);
            Redis::expire($hourKey, 86400 * 7);
            
            Log::channel('rate_limit')->warning('Rate limit exceeded', $metrics);
        }
    }
    
    public function getHits(string $key, int $hours = 24): array
    {
        $results = [];
        
        for ($i = 0; $i < $hours; $i++) {
            $hourKey = 'rate_limit:hits:' . now()->subHours($i)->format('Y-m-d:H');
            $hits = Redis::hget($hourKey, $key);
            
            $results[now()->subHours($i)->format('Y-m-d H:00')] = (int) $hits;
        }
        
        return array_reverse($results);
    }
    
    public function getTopOffenders(int $hours = 24, int $limit = 10): array
    {
        $totals = [];
        
        for ($i = 0; $i < $hours; $i++) {
            $hourKey = 'rate_limit:hits:' . now()->subHours($i)->format('Y-m-d:H');
            $hits = Redis::hgetall($hourKey);
            
            foreach ($hits as $key => $count) {
                $totals[$key] = ($totals[$key] ?? 0) + $count;
            }
        }
        
        arsort($totals);
        
        return array_slice($totals, 0, $limit, true);
    }
}

Best Practices

1. Choose the Right Algorithm

Use Case Recommended Algorithm
API general Sliding Window
Burst protection Token Bucket
Queue processing Leaky Bucket
Cost-based billing Token Bucket với costs

2. Multiple Layers

// Apply multiple rate limits
Route::middleware([
    'advanced.rate:sliding_window:1000,3600|user',  // 1000/hour per user
    'advanced.rate:sliding_window:100,60|ip',       // 100/minute per IP
    'advanced.rate:token_bucket:50,5|user',         // burst protection
])->group(function () {
    // routes
});

3. Graceful Degradation

// Return cached/stale data when rate limited
if (!$result->allowed && $cachedData = Cache::get($cacheKey)) {
    return response()->json([
        'data' => $cachedData,
        'stale' => true,
        'message' => 'Rate limited, returning cached data',
    ], 200, $result->headers());
}

Kết Luận

Advanced rate limiting giúp:

  1. Smooth traffic với sliding window
  2. Handle bursts với token bucket
  3. Fair queueing với leaky bucket
  4. Scale horizontally với distributed limiting
  5. Flexible pricing với cost-based limiting

Checklist Implementation

  • Chọn thuật toán phù hợp với use case
  • Implement distributed limiting với Redis
  • Set up tiered limits theo subscription
  • Add cost-based limiting cho expensive endpoints
  • Monitor và alert rate limit hits
  • Graceful responses với retry-after headers

Tài Liệu Tham Khảo

Bình luận