Rate Limiting và API Throttling trong Laravel — Bảo vệ ứng dụng khỏi lạm dụng
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 windowX-RateLimit-Remaining: Số request còn lạiRetry-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:
- Dùng Redis Sorted Set (ZSET) với score = timestamp
- Mỗi request thêm 1 member vào set
- Xóa tất cả members cũ hơn
now - windowSeconds - Đếm số members còn lại = số requests trong sliding window
- 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:
- Bắt đầu rộng rãi, thu hẹp dần khi thấy lạm dụng
- Luôn trả về headers để client biết limit
- Custom response message rõ ràng, thân thiện
- Monitor ai bị limit để điều chỉnh ngưỡng
- Document rõ ràng cho API consumers