Rate Limiting và API Throttling trong Laravel — Bảo vệ ứng dụng khỏi lạm dụng

· 12 min read

Không có rate limiting, ứng dụng của bạn giống như một quán ăn không giới hạn số khách — ai cũng vào được, bao nhiêu cũng được, và server của bạn sẽ sụp khi có đợt "spam" đổ về.

Rate limiting giúp:

  • Bảo vệ server khỏi DDoS và brute-force attacks
  • Đảm bảo công bằng giữa các users
  • Kiểm soát chi phí cho API (đặc biệt khi tích hợp với dịch vụ trả phí như OpenAI, Stripe)
  • Tuân thủ SLA của SaaS theo từng tier/plan

Rate Limiting cơ bản trong Laravel

Laravel cung cấp middleware throttle sẵn:

// routes/api.php
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::get('/posts/{post}', [PostController::class, 'show']);
});

Ý nghĩa: 60 requests mỗi 1 phút cho mỗi user (xác định bằng IP hoặc authenticated user ID).

Khi vượt limit, Laravel tự động trả về:

HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0

Giải thích các headers:

  • X-RateLimit-Limit: Tổng số request được phép trong window
  • X-RateLimit-Remaining: Số request còn lại
  • Retry-After: Bao nhiêu giây nữa limit sẽ reset

Named Rate Limiters (Laravel 8+)

Thay vì hardcode số trong route, bạn nên define named limiters:

// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;

public function boot(): void
{
    // Limiter cho API chung
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)
            ->by($request->user()?->id ?: $request->ip());
    });

    // Limiter cho login (chống brute-force)
    RateLimiter::for('login', function (Request $request) {
        return Limit::perMinute(5)
            ->by($request->input('email') . '|' . $request->ip())
            ->response(function (Request $request, array $headers) {
                return response()->json([
                    'message' => 'Quá nhiều lần thử. Vui lòng đợi.',
                    'retry_after' => $headers['Retry-After'],
                ], 429, $headers);
            });
    });

    // Limiter cho upload (giới hạn chặt hơn)
    RateLimiter::for('uploads', function (Request $request) {
        return Limit::perMinute(10)->by($request->user()->id);
    });

    // Limiter cho webhook (rộng hơn vì từ service bên ngoài)
    RateLimiter::for('webhooks', function (Request $request) {
        return Limit::perMinute(500)->by($request->ip());
    });
}

Sử dụng trong routes:

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Route::middleware('throttle:api')->group(function () {
    Route::apiResource('posts', PostController::class);
});

Route::post('/upload', [UploadController::class, 'store'])
    ->middleware(['auth', 'throttle:uploads']);

Giải thích ->by(): Xác định "key" để nhóm requests. Ví dụ:

  • ->by($request->ip()): Giới hạn theo IP. Mọi user cùng IP chia sẻ limit.
  • ->by($request->user()->id): Giới hạn theo user. Mỗi user có limit riêng.
  • ->by($request->input('email') . '|' . $request->ip()): Kết hợp cả email và IP cho login. Ngăn chặn brute-force từ nhiều IP nhắm vào cùng 1 tài khoản.

Rate Limiting theo Plan (SaaS)

Đây là pattern quan trọng nhất cho SaaS. Mỗi tier (free, pro, enterprise) có limit khác nhau:

// app/Providers/AppServiceProvider.php
RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    if (!$user) {
        // Guest: 30 requests/phút
        return Limit::perMinute(30)->by($request->ip());
    }

    // Lấy limit từ plan của user
    return match ($user->plan) {
        'free'       => Limit::perMinute(60)->by($user->id),
        'pro'        => Limit::perMinute(300)->by($user->id),
        'enterprise' => Limit::perMinute(1000)->by($user->id),
        default      => Limit::perMinute(60)->by($user->id),
    };
});

Nâng cao: Rate Limit theo cả ngày và phút

Đôi khi bạn muốn giới hạn cả burst (ngắn hạn) và daily quota (dài hạn):

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    if (!$user) {
        return Limit::perMinute(30)->by($request->ip());
    }

    $limits = match ($user->plan) {
        'free' => [
            Limit::perMinute(60)->by($user->id),
            Limit::perDay(1000)->by($user->id),
        ],
        'pro' => [
            Limit::perMinute(300)->by($user->id),
            Limit::perDay(10000)->by($user->id),
        ],
        'enterprise' => [
            Limit::perMinute(1000)->by($user->id),
            Limit::perDay(100000)->by($user->id),
        ],
        default => [
            Limit::perMinute(60)->by($user->id),
        ],
    };

    return $limits;
});

Giải thích: Khi trả về array, Laravel kiểm tra tất cả limits. Request bị reject nếu bất kỳ limit nào bị vượt. Ví dụ user "free" có thể gửi 60 req/phút, nhưng tối đa 1000 req/ngày. Điều này ngăn user gửi 60 req/phút liên tục cả ngày (= 86,400 req).

Custom Rate Limiter: Sliding Window

Laravel mặc định sử dụng Fixed Window algorithm — reset counter mỗi phút tại thời điểm cố định. Nhược điểm là user có thể gửi đúng 60 requests ở cuối window, rồi thêm 60 requests ở đầu window tiếp theo = 120 requests trong 2 giây.

Sliding Window giải quyết vấn đề này:

// app/Services/SlidingWindowRateLimiter.php
namespace App\Services;

use Illuminate\Support\Facades\Redis;

class SlidingWindowRateLimiter
{
    /**
     * Kiểm tra và ghi nhận request.
     *
     * @param string $key Định danh (user_id, IP, ...)
     * @param int $maxAttempts Số request tối đa
     * @param int $windowSeconds Kích thước window (giây)
     * @return array{allowed: bool, remaining: int, retryAfter: int}
     */
    public function attempt(string $key, int $maxAttempts, int $windowSeconds): array
    {
        $now = microtime(true);
        $windowStart = $now - $windowSeconds;
        $member = $now . ':' . uniqid('', true);

        $result = Redis::pipeline(function ($pipe) use ($key, $windowStart, $now, $member, $windowSeconds) {
            // Xóa entries cũ hơn window
            $pipe->zremrangebyscore($key, '-inf', $windowStart);
            // Thêm request hiện tại
            $pipe->zadd($key, $now, $member);
            // Đếm số entries trong window
            $pipe->zcard($key);
            // Set TTL để tự cleanup
            $pipe->expire($key, $windowSeconds);
        });

        $currentCount = $result[2];
        $allowed = $currentCount <= $maxAttempts;

        if (!$allowed) {
            // Tìm entry cũ nhất, tính thời gian nó hết hạn
            $oldestEntries = Redis::zrange($key, 0, 0, 'WITHSCORES');
            $oldestScore = !empty($oldestEntries) ? reset($oldestEntries) : $now;
            $retryAfter = (int) ceil(($oldestScore + $windowSeconds) - $now);
        }

        return [
            'allowed'    => $allowed,
            'remaining'  => max(0, $maxAttempts - $currentCount),
            'retryAfter' => $allowed ? 0 : ($retryAfter ?? $windowSeconds),
            'limit'      => $maxAttempts,
        ];
    }
}

Giải thích thuật toán:

  1. Dùng Redis Sorted Set (ZSET) với score = timestamp
  2. Mỗi request thêm 1 member vào set
  3. Xóa tất cả members cũ hơn now - windowSeconds
  4. Đếm số members còn lại = số requests trong sliding window
  5. Nếu count > maxAttempts → reject

So sánh:

Fixed Window (60 req/phút):
|-------- Minute 1 --------|-------- Minute 2 --------|
                     [60 req][60 req]
                     ↑ 120 requests trong 2 giây! ↑

Sliding Window (60 req/phút):
|<--- luôn nhìn lại 60 giây --->|
Không bao giờ vượt quá 60 trong bất kỳ 60 giây nào

Middleware sử dụng Sliding Window

// app/Http/Middleware/SlidingWindowThrottle.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Services\SlidingWindowRateLimiter;

class SlidingWindowThrottle
{
    public function __construct(
        private SlidingWindowRateLimiter $limiter,
    ) {}

    public function handle(Request $request, Closure $next, int $maxAttempts = 60, int $windowSeconds = 60)
    {
        $key = 'rate_limit:' . ($request->user()?->id ?: $request->ip());

        $result = $this->limiter->attempt($key, $maxAttempts, $windowSeconds);

        if (!$result['allowed']) {
            return response()->json([
                'message' => 'Too many requests. Please slow down.',
                'retry_after' => $result['retryAfter'],
            ], 429, [
                'X-RateLimit-Limit' => $result['limit'],
                'X-RateLimit-Remaining' => 0,
                'Retry-After' => $result['retryAfter'],
            ]);
        }

        $response = $next($request);

        return $response->withHeaders([
            'X-RateLimit-Limit' => $result['limit'],
            'X-RateLimit-Remaining' => $result['remaining'],
        ]);
    }
}

Đăng ký middleware:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'throttle.sliding' => \App\Http\Middleware\SlidingWindowThrottle::class,
    ]);
})

Sử dụng:

// 100 requests mỗi 60 giây (sliding window)
Route::middleware('throttle.sliding:100,60')->group(function () {
    Route::get('/api/search', [SearchController::class, 'index']);
});

Rate Limiting cho Specific Actions

Không phải mọi endpoint đều cần cùng limit. Một số hành động cần bảo vệ chặt hơn:

// app/Providers/AppServiceProvider.php

// Gửi email (tránh spam)
RateLimiter::for('send-email', function (Request $request) {
    return Limit::perHour(10)->by($request->user()->id)
        ->response(function () {
            return response()->json([
                'message' => 'Bạn đã gửi quá nhiều email. Thử lại sau 1 giờ.',
            ], 429);
        });
});

// Password reset (chống brute-force)
RateLimiter::for('password-reset', function (Request $request) {
    return [
        Limit::perMinute(3)->by($request->ip()),
        Limit::perHour(10)->by($request->input('email')),
    ];
});

// Export data (tốn tài nguyên)
RateLimiter::for('export', function (Request $request) {
    return Limit::perHour(5)->by($request->user()->id)
        ->response(function () {
            return response()->json([
                'message' => 'Bạn chỉ được export 5 lần mỗi giờ.',
            ], 429);
        });
});

// API key-based limiting
RateLimiter::for('api-key', function (Request $request) {
    $apiKey = $request->header('X-API-Key');

    if (!$apiKey) {
        return Limit::perMinute(10)->by($request->ip());
    }

    // Lấy limit từ database dựa trên API key
    $keyRecord = ApiKey::where('key', hash('sha256', $apiKey))->first();

    if (!$keyRecord) {
        return Limit::none(); // Sẽ bị reject bởi auth middleware
    }

    return Limit::perMinute($keyRecord->rate_limit)
        ->by('api_key:' . $keyRecord->id);
});

Retry Logic cho Client

Khi API trả về 429, client nên xử lý thông minh thay vì spam thêm:

// Ví dụ: Laravel HTTP Client gọi API bên ngoài
use Illuminate\Support\Facades\Http;

$response = Http::retry(3, function (int $attempt, \Exception $exception) {
    // Exponential backoff: 1s, 2s, 4s
    $baseDelay = 1000;

    // Nếu có Retry-After header, dùng nó
    if ($exception instanceof \Illuminate\Http\Client\RequestException) {
        $retryAfter = $exception->response?->header('Retry-After');
        if ($retryAfter) {
            return (int) $retryAfter * 1000; // convert to ms
        }
    }

    return $baseDelay * (2 ** ($attempt - 1));
}, function (\Exception $exception) {
    // Chỉ retry khi bị rate limited
    return $exception instanceof \Illuminate\Http\Client\RequestException
        && $exception->response?->status() === 429;
})->get('https://api.example.com/data');

Giải thích Exponential Backoff: Thay vì retry ngay lập tức (sẽ bị 429 tiếp), client đợi lâu hơn mỗi lần: 1 giây → 2 giây → 4 giây. Kết hợp với Retry-After header để biết chính xác thời gian chờ.

Rate Limit cho Queue Jobs

Đôi khi bạn cần giới hạn tốc độ xử lý queue jobs (ví dụ: gọi external API có rate limit):

// app/Jobs/SendNewsletterJob.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Queue\Middleware\RateLimited;

class SendNewsletterJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public int $subscriberId,
    ) {}

    /**
     * Queue middleware: giới hạn 100 emails/phút
     */
    public function middleware(): array
    {
        return [
            new RateLimited('newsletter'),
        ];
    }

    public function handle(): void
    {
        // Gửi email...
    }

    /**
     * Nếu bị rate limited, retry sau 60 giây
     */
    public function retryAfter(): int
    {
        return 60;
    }
}

Khai báo limiter:

// AppServiceProvider
RateLimiter::for('newsletter', function () {
    return Limit::perMinute(100);
});

Rate Limit cho external API calls

// app/Jobs/SyncToExternalApiJob.php
use Illuminate\Queue\Middleware\RateLimitedWithRedis;

class SyncToExternalApiJob implements ShouldQueue
{
    public function middleware(): array
    {
        // RateLimitedWithRedis chính xác hơn RateLimited
        // khi chạy nhiều workers đồng thời
        return [
            (new RateLimitedWithRedis('external-api'))
                ->dontRelease(), // Nếu bị limit, xóa job thay vì release lại queue
        ];
    }
}

Monitoring Rate Limits

Biết ai đang bị rate limit giúp bạn điều chỉnh ngưỡng phù hợp:

// app/Http/Middleware/LogRateLimitHits.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class LogRateLimitHits
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        if ($response->getStatusCode() === 429) {
            Log::warning('Rate limit hit', [
                'ip'     => $request->ip(),
                'user'   => $request->user()?->id,
                'path'   => $request->path(),
                'method' => $request->method(),
                'limit'  => $response->headers->get('X-RateLimit-Limit'),
            ]);
        }

        return $response;
    }
}

API Documentation — Thông báo cho consumers

Luôn document rate limits rõ ràng trong API docs:

{
  "rate_limits": {
    "free": {
      "requests_per_minute": 60,
      "requests_per_day": 1000,
      "note": "Shared across all endpoints"
    },
    "pro": {
      "requests_per_minute": 300,
      "requests_per_day": 10000,
      "note": "Per API key"
    },
    "enterprise": {
      "requests_per_minute": 1000,
      "requests_per_day": 100000,
      "note": "Custom limits available"
    }
  },
  "headers": {
    "X-RateLimit-Limit": "Maximum requests allowed",
    "X-RateLimit-Remaining": "Requests remaining in window",
    "Retry-After": "Seconds until limit resets (only on 429)"
  },
  "best_practices": [
    "Cache responses when possible",
    "Use webhooks instead of polling",
    "Implement exponential backoff on 429",
    "Contact us for higher limits"
  ]
}

Tổng kết: Chọn strategy phù hợp

Tình huống Strategy
API công khai Named limiter + IP-based
API có auth Plan-based limiter + User ID
Login/Password reset Kết hợp IP + email, limit thấp
File upload Per-user, limit thấp
Export dữ liệu Per-user, per-hour
Queue jobs gọi API ngoài RateLimitedWithRedis middleware
SaaS multi-tier Array limits (per-minute + per-day)
Traffic burst cao Sliding window limiter

Nguyên tắc:

  1. Bắt đầu rộng rãi, thu hẹp dần khi thấy lạm dụng
  2. Luôn trả về headers để client biết limit
  3. Custom response message rõ ràng, thân thiện
  4. Monitor ai bị limit để điều chỉnh ngưỡng
  5. Document rõ ràng cho API consumers

Bình luận