Rate Limiting Recipe#
Protect your API from abuse with flexible rate limiting.
Problem#
You need to limit how many requests users can make to prevent abuse, ensure fair usage, and protect your infrastructure.
Solution#
1. Simple Rate Limiter#
<?php
// src/Service/RateLimiter.php
namespace App\Service;
use Psr\SimpleCache\CacheInterface;
final class RateLimiter
{
public function __construct(
private CacheInterface $cache
) {}
public function attempt(
string $key,
int $maxAttempts,
int $decaySeconds
): bool {
$attempts = $this->attempts($key);
if ($attempts >= $maxAttempts) {
return false;
}
$this->hit($key, $decaySeconds);
return true;
}
public function tooManyAttempts(string $key, int $maxAttempts): bool
{
return $this->attempts($key) >= $maxAttempts;
}
public function hit(string $key, int $decaySeconds): int
{
$cacheKey = $this->getCacheKey($key);
$attempts = (int) $this->cache->get($cacheKey, 0);
$attempts++;
$this->cache->set($cacheKey, $attempts, $decaySeconds);
return $attempts;
}
public function attempts(string $key): int
{
return (int) $this->cache->get($this->getCacheKey($key), 0);
}
public function resetAttempts(string $key): void
{
$this->cache->delete($this->getCacheKey($key));
}
public function remaining(string $key, int $maxAttempts): int
{
return max(0, $maxAttempts - $this->attempts($key));
}
public function availableIn(string $key): int
{
$cacheKey = $this->getCacheKey($key);
// Get TTL from cache (implementation-specific)
return $this->cache->getTtl($cacheKey) ?? 0;
}
private function getCacheKey(string $key): string
{
return 'rate_limit:' . $key;
}
}
2. Rate Limit Middleware#
<?php
// src/Middleware/RateLimitMiddleware.php
namespace App\Middleware;
use App\Service\RateLimiter;
use Infocyph\Webrick\Request\Request;
use Infocyph\Webrick\Response\Response;
use Closure;
final class RateLimitMiddleware
{
public function __construct(
private RateLimiter $limiter,
private int $maxAttempts = 60,
private int $decaySeconds = 60
) {}
public function __invoke(Request $r, Closure $next): Response
{
$key = $this->resolveRequestSignature($r);
if ($this->limiter->tooManyAttempts($key, $this->maxAttempts)) {
return $this->buildRateLimitedResponse($key);
}
$this->limiter->hit($key, $this->decaySeconds);
$response = $next($r);
return $this->addHeaders(
$response,
$this->maxAttempts,
$this->limiter->remaining($key, $this->maxAttempts),
$this->limiter->availableIn($key)
);
}
protected function resolveRequestSignature(Request $r): string
{
// Use IP + route
$ip = $r->getAttribute('client_ip', $r->server('REMOTE_ADDR'));
$route = $r->getPath();
return sha1($ip . '|' . $route);
}
protected function buildRateLimitedResponse(string $key): Response
{
$retryAfter = $this->limiter->availableIn($key);
return Response::json([
'message' => 'Too many requests',
'retry_after' => $retryAfter
], 429)->withHeader('Retry-After', (string) $retryAfter);
}
protected function addHeaders(
Response $response,
int $limit,
int $remaining,
int $retryAfter
): Response {
return $response
->withHeader('X-RateLimit-Limit', (string) $limit)
->withHeader('X-RateLimit-Remaining', (string) $remaining)
->withHeader('X-RateLimit-Reset', (string) (time() + $retryAfter));
}
}
3. Per-User Rate Limiting#
final class PerUserRateLimitMiddleware extends RateLimitMiddleware
{
protected function resolveRequestSignature(Request $r): string
{
$userId = $r->getAttribute('auth.user_id');
if ($userId) {
return "user:{$userId}";
}
// Fallback to IP for unauthenticated
return 'ip:' . $r->getAttribute('client_ip', $r->server('REMOTE_ADDR'));
}
}
// Usage
Route::get('/api/data', $handler)->withMiddleware([
'auth',
new PerUserRateLimitMiddleware($limiter, maxAttempts: 1000, decaySeconds: 60)
]);
4. Dynamic Rate Limits (Tiered)#
final class TieredRateLimitMiddleware extends RateLimitMiddleware
{
public function __construct(
private RateLimiter $limiter,
private array $tiers = []
) {
$this->tiers = $tiers ?: [
'free' => ['limit' => 60, 'decay' => 60],
'pro' => ['limit' => 300, 'decay' => 60],
'enterprise' => ['limit' => PHP_INT_MAX, 'decay' => 60]
];
}
public function __invoke(Request $r, Closure $next): Response
{
$tier = $this->getUserTier($r);
$config = $this->tiers[$tier];
$this->maxAttempts = $config['limit'];
$this->decaySeconds = $config['decay'];
return parent::__invoke($r, $next);
}
private function getUserTier(Request $r): string
{
$userId = $r->getAttribute('auth.user_id');
if (!$userId) {
return 'free';
}
$user = UserRepository::find($userId);
return $user['subscription_tier'] ?? 'free';
}
}
5. Cost-Based Rate Limiting#
final class CostBasedRateLimitMiddleware
{
public function __construct(
private RateLimiter $limiter,
private int $maxCost = 100,
private int $decaySeconds = 60
) {}
public function __invoke(Request $r, Closure $next): Response
{
$key = $this->resolveKey($r);
$cost = $r->getAttribute('rate_limit.cost', 1);
$currentCost = $this->limiter->attempts($key);
if ($currentCost + $cost > $this->maxCost) {
return Response::json([
'message' => 'Rate limit exceeded',
'current_cost' => $currentCost,
'max_cost' => $this->maxCost
], 429);
}
// Add cost
for ($i = 0; $i < $cost; $i++) {
$this->limiter->hit($key, $this->decaySeconds);
}
return $next($r);
}
private function resolveKey(Request $r): string
{
$userId = $r->getAttribute('auth.user_id');
return "cost:user:{$userId}";
}
}
// Usage - mark expensive operations
Route::post('/api/expensive', function(Request $r) {
// This operation costs 10 tokens
$r = $r->withAttribute('rate_limit.cost', 10);
// ... expensive operation ...
return Response::json(['result' => $result]);
})->withMiddleware([new CostBasedRateLimitMiddleware($limiter, maxCost: 100)]);
Advanced Patterns#
Sliding Window#
final class SlidingWindowRateLimiter
{
public function __construct(
private CacheInterface $cache
) {}
public function attempt(string $key, int $maxAttempts, int $windowSeconds): bool
{
$now = time();
$windowStart = $now - $windowSeconds;
// Get timestamps of requests in current window
$timestamps = $this->cache->get($key, []);
// Filter out old timestamps
$timestamps = array_filter($timestamps, fn($ts) => $ts > $windowStart);
if (count($timestamps) >= $maxAttempts) {
return false;
}
// Add current timestamp
$timestamps[] = $now;
$this->cache->set($key, $timestamps, $windowSeconds);
return true;
}
}
Token Bucket Algorithm#
final class TokenBucketRateLimiter
{
private int $capacity;
private float $refillRate; // tokens per second
public function __construct(
private CacheInterface $cache,
int $capacity = 100,
float $refillRate = 1.0
) {
$this->capacity = $capacity;
$this->refillRate = $refillRate;
}
public function allow(string $key, int $tokens = 1): bool
{
$bucket = $this->cache->get($key, [
'tokens' => $this->capacity,
'last_refill' => time()
]);
$now = time();
$timePassed = $now - $bucket['last_refill'];
// Refill tokens based on time passed
$bucket['tokens'] = min(
$this->capacity,
$bucket['tokens'] + ($timePassed * $this->refillRate)
);
$bucket['last_refill'] = $now;
// Check if enough tokens
if ($bucket['tokens'] < $tokens) {
$this->cache->set($key, $bucket, 3600);
return false;
}
// Consume tokens
$bucket['tokens'] -= $tokens;
$this->cache->set($key, $bucket, 3600);
return true;
}
public function remaining(string $key): float
{
$bucket = $this->cache->get($key, [
'tokens' => $this->capacity,
'last_refill' => time()
]);
$now = time();
$timePassed = $now - $bucket['last_refill'];
return min(
$this->capacity,
$bucket['tokens'] + ($timePassed * $this->refillRate)
);
}
}
Distributed Rate Limiting (Redis Lua)#
final class RedisRateLimiter
{
private \Redis $redis;
public function __construct(\Redis $redis)
{
$this->redis = $redis;
}
public function attempt(string $key, int $maxAttempts, int $decaySeconds): bool
{
$script = <<<'LUA'
local key = KEYS[1]
local max = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= max then
return 0
end
redis.call('INCR', key)
if not current then
redis.call('EXPIRE', key, ttl)
end
return 1
LUA;
$result = $this->redis->eval($script, [$key, $maxAttempts, $decaySeconds], 1);
return $result === 1;
}
public function attempts(string $key): int
{
return (int) $this->redis->get($key);
}
public function reset(string $key): void
{
$this->redis->del($key);
}
}
Testing#
Unit Test#
<?php
use PHPUnit\Framework\TestCase;
use App\Service\RateLimiter;
class RateLimiterTest extends TestCase
{
public function testAllowsWithinLimit(): void
{
$cache = new \Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter();
$limiter = new RateLimiter($cache);
$key = 'test:user:1';
// First 5 should succeed
for ($i = 0; $i < 5; $i++) {
$this->assertTrue($limiter->attempt($key, 5, 60));
}
// 6th should fail
$this->assertFalse($limiter->attempt($key, 5, 60));
}
public function testResetClearsAttempts(): void
{
$cache = new \Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter();
$limiter = new RateLimiter($cache);
$key = 'test:user:2';
$limiter->hit($key, 60);
$limiter->hit($key, 60);
$this->assertEquals(2, $limiter->attempts($key));
$limiter->resetAttempts($key);
$this->assertEquals(0, $limiter->attempts($key));
}
}
Integration Test#
#!/bin/bash
# test-rate-limit.sh
set -e
BASE_URL="http://localhost:8000"
ENDPOINT="/api/test"
echo "Testing rate limit (max: 5/minute)..."
SUCCESS=0
for i in {1..10}; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$ENDPOINT")
if [ "$HTTP_CODE" = "200" ]; then
SUCCESS=$((SUCCESS + 1))
elif [ "$HTTP_CODE" = "429" ]; then
echo "✅ Rate limited after $SUCCESS requests"
exit 0
fi
done
echo "❌ Expected rate limit after 5 requests, got $SUCCESS/10"
exit 1
Monitoring & Alerts#
Metrics Collection#
final class RateLimitMetrics
{
public static function recordHit(string $key, bool $allowed): void
{
$metrics = [
'timestamp' => time(),
'key' => $key,
'allowed' => $allowed,
'endpoint' => $_SERVER['REQUEST_URI'] ?? 'unknown'
];
// Send to metrics system (Prometheus, StatsD, etc.)
Metrics::increment('rate_limit_hits_total', [
'result' => $allowed ? 'allowed' : 'denied',
'endpoint' => $metrics['endpoint']
]);
}
}
// In middleware
if ($this->limiter->tooManyAttempts($key, $this->maxAttempts)) {
RateLimitMetrics::recordHit($key, false);
return $this->buildRateLimitedResponse($key);
}
RateLimitMetrics::recordHit($key, true);
Alert Rules#
# Prometheus alert rules
groups:
- name: rate_limiting
rules:
- alert: HighRateLimitDenials
expr: rate(rate_limit_hits_total{result="denied"}[5m]) > 10
for: 5m
annotations:
summary: "High rate of rate limit denials"
description: "{{ $value }} requests/sec being rate limited"
- alert: PossibleAttack
expr: sum(rate(rate_limit_hits_total{result="denied"}[1m])) by (client_ip) > 50
annotations:
summary: "Possible attack from {{ $labels.client_ip }}"
Best Practices#
1. Set Appropriate Limits#
// Public endpoints (strict)
Route::post('/api/public/search', $handler)
->withMiddleware(['throttle:10,60']); // 10/minute
// Authenticated endpoints (moderate)
Route::get('/api/user/profile', $handler)
->withMiddleware(['auth', 'throttle:120,60']); // 120/minute
// Premium users (relaxed)
Route::get('/api/premium/data', $handler)
->withMiddleware(['auth', new TieredRateLimitMiddleware($limiter)]);
2. Return Proper Headers#
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1234567890
Retry-After: 60
{
"message": "Too many requests",
"retry_after": 60
}
3. Use Multiple Buckets#
// Per-route limiting
$routeLimiter = new RateLimitMiddleware($limiter, 60, 60);
// Global limiting (all routes)
$globalLimiter = new RateLimitMiddleware($limiter, 1000, 60);
// Apply both
$preGlobal = [$globalLimiter];
$perRoute = [$routeLimiter];
4. Whitelist Internal Services#
final class SmartRateLimitMiddleware extends RateLimitMiddleware
{
private array $whitelist = ['10.0.0.0/8', '127.0.0.1'];
public function __invoke(Request $r, Closure $next): Response
{
$ip = $r->getAttribute('client_ip');
// Skip rate limiting for whitelisted IPs
if ($this->isWhitelisted($ip)) {
return $next($r);
}
return parent::__invoke($r, $next);
}
private function isWhitelisted(string $ip): bool
{
foreach ($this->whitelist as $range) {
if ($this->ipInRange($ip, $range)) {
return true;
}
}
return false;
}
}
5. Implement Gradual Backoff#
final class BackoffRateLimitMiddleware extends RateLimitMiddleware
{
protected function buildRateLimitedResponse(string $key): Response
{
$violations = $this->limiter->attempts("violations:{$key}");
$this->limiter->hit("violations:{$key}", 3600); // Track violations
// Exponential backoff
$retryAfter = min(60 * (2 ** $violations), 3600); // Cap at 1 hour
return Response::json([
'message' => 'Too many requests',
'retry_after' => $retryAfter,
'violations' => $violations + 1
], 429)->withHeader('Retry-After', (string) $retryAfter);
}
}
6. Log Violations#
if ($this->limiter->tooManyAttempts($key, $this->maxAttempts)) {
$this->logger->warning('Rate limit exceeded', [
'key' => $key,
'ip' => $r->getAttribute('client_ip'),
'user_id' => $r->getAttribute('auth.user_id'),
'endpoint' => $r->getPath(),
'attempts' => $this->limiter->attempts($key)
]);
return $this->buildRateLimitedResponse($key);
}
Common Patterns#
Login Rate Limiting#
Route::post('/login', function(Request $r) use ($limiter) {
$email = $r->input('email');
$ip = $r->getAttribute('client_ip');
// Rate limit by email
$emailKey = "login:email:{$email}";
if ($limiter->tooManyAttempts($emailKey, 5, 300)) {
return Response::json([
'error' => 'Too many login attempts for this email'
], 429);
}
// Rate limit by IP
$ipKey = "login:ip:{$ip}";
if ($limiter->tooManyAttempts($ipKey, 10, 300)) {
return Response::json([
'error' => 'Too many login attempts from this IP'
], 429);
}
// Attempt authentication
$user = AuthService::attempt($email, $r->input('password'));
if (!$user) {
$limiter->hit($emailKey, 300);
$limiter->hit($ipKey, 300);
return Response::json(['error' => 'Invalid credentials'], 401);
}
// Success - reset counters
$limiter->resetAttempts($emailKey);
$limiter->resetAttempts($ipKey);
return Response::json(['token' => $jwt->generate($user['id'])]);
});
API Key Rate Limiting#
final class ApiKeyRateLimitMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
$apiKey = $r->getHeaderLine('X-API-Key');
if (!$apiKey) {
return Response::json(['error' => 'API key required'], 401);
}
$key = "api_key:{$apiKey}";
$limit = $this->getLimitForApiKey($apiKey);
if ($this->limiter->tooManyAttempts($key, $limit, 60)) {
return Response::json([
'error' => 'Rate limit exceeded',
'limit' => $limit,
'window' => '60 seconds'
], 429);
}
$this->limiter->hit($key, 60);
return $next($r);
}
private function getLimitForApiKey(string $apiKey): int
{
$apiKeyData = DB::queryOne('SELECT * FROM api_keys WHERE key = ?', [$apiKey]);
return $apiKeyData['rate_limit'] ?? 60;
}
}
Webhook Verification Rate Limiting#
Route::post('/webhooks/stripe', function(Request $r) use ($limiter) {
$signature = $r->getHeaderLine('Stripe-Signature');
// Rate limit webhook verifications
$key = "webhook:stripe:verify";
if ($limiter->tooManyAttempts($key, 100, 60)) {
return Response::json(['error' => 'Too many requests'], 429);
}
if (!$this->verifyStripeSignature($signature, $r->getContent())) {
$limiter->hit($key, 60);
return Response::json(['error' => 'Invalid signature'], 401);
}
// Process webhook
$this->processStripeWebhook($r->json());
return Response::json(['status' => 'success']);
});
Summary#
This recipe provides:
✅ Flexible rate limiter service
✅ Multiple rate limiting strategies
✅ Per-user and per-IP limiting
✅ Tiered/dynamic rate limits
✅ Cost-based rate limiting
✅ Distributed rate limiting with Redis
✅ Monitoring and alerting
Production checklist:
Set appropriate limits per endpoint type
Return proper rate limit headers
Implement per-user and global limits
Use Redis for distributed setups
Whitelist internal services
Log rate limit violations
Monitor denial rates
Implement gradual backoff for repeat offenders
Test rate limiting under load