Throttle Middleware#
Rate limiting middleware that enforces request limits per client/user with configurable windows and costs.
Configuration#
use Infocyph\Webrick\Middleware\ThrottleMiddleware;
use Psr\Cache\CacheItemPoolInterface;
$preGlobal[] = new ThrottleMiddleware(
max: 60, // Maximum requests per window
window: 60, // Window duration in seconds
pool: $cache, // PSR-6 cache pool for counters
retryAsDate: false, // Retry-After as HTTP-date (true) or seconds (false)
identifierResolver: null, // Custom key resolver (default: client IP)
emitStandardRateLimit: true, // Emit RateLimit-* headers (RFC draft)
scope: 'global', // Logical bucket name/namespace
costAttribute: 'rate_cost.thm', // Request attribute for per-request cost
bypass: null // Closure to bypass throttle
);
Constructor Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
required |
Maximum requests allowed per window |
|
|
required |
Time window in seconds |
|
|
required |
PSR-6 cache for storing counters |
|
|
|
|
|
|
|
Custom function to resolve identifier from request |
|
|
|
Emit |
|
|
|
Namespace for cache keys (allows multiple buckets) |
|
|
|
Request attribute name for custom cost |
|
|
|
Function to bypass throttle for certain requests |
Usage Patterns#
Basic Rate Limiting#
// 60 requests per minute, globally
$preGlobal[] = new ThrottleMiddleware(
max: 60,
window: 60,
pool: Cache::pool('throttle')
);
Per-Route Throttling (Via Alias)#
First, register the alias:
use Infocyph\Webrick\Router\Dispatch\MiddlewareAliases;
MiddlewareAliases::register(
'throttle',
static fn(...$params) => new ThrottleMiddleware(
max: (int)($params[0] ?? 60),
window: (int)($params[1] ?? 60),
pool: Cache::pool('throttle')
)
);
Then use in routes:
// Strict limit on login
Route::post('/login', [AuthController::class, 'login'], [
'middleware' => ['throttle:5,300'] // 5 attempts per 5 minutes
]);
// Relaxed limit on API
Route::get('/api/users', [UserController::class, 'index'], [
'middleware' => ['throttle:120,60'] // 120 requests per minute
]);
Per-User vs Per-IP#
Default (Per-IP):
new ThrottleMiddleware(
max: 60,
window: 60,
pool: $cache
// Uses client IP by default
);
Per-User (Custom Resolver):
new ThrottleMiddleware(
max: 1000, // Authenticated users get higher limit
window: 60,
pool: $cache,
identifierResolver: static function (Request $r): string {
// Use authenticated user ID if available
$userId = $r->getAttribute('auth.user_id');
return $userId ? "user:{$userId}" : "ip:{$r->getAttribute('client_ip')}";
}
);
Per-Request Cost#
Charge different rates for different endpoints:
// In middleware or route handler
Route::post('/expensive-operation', function (Request $r) {
// This request costs 10 tokens
$r = $r->withAttribute('rate_cost.thm', 10);
return $next($r);
})->withMiddleware([
new ThrottleMiddleware(max: 100, window: 60, pool: $cache)
]);
// Regular request costs 1 token (default)
Route::get('/cheap-read', fn() => Response::json(['ok' => true]))->withMiddleware([
new ThrottleMiddleware(max: 100, window: 60, pool: $cache)
]);
Result: Expensive operation consumes 10 tokens, cheap read consumes 1.
Bypass for Internal/Health Checks#
new ThrottleMiddleware(
max: 60,
window: 60,
pool: $cache,
bypass: static function (Request $r): bool {
// Skip throttle for health checks
if ($r->getPath() === '/health') {
return true;
}
// Skip for internal IPs
$ip = $r->getAttribute('client_ip');
if (str_starts_with($ip, '10.') || str_starts_with($ip, '192.168.')) {
return true;
}
// Skip for API keys with bypass privilege
$apiKey = $r->getHeaderLine('X-API-Key');
if ($apiKey && ApiKeyService::hasBypass($apiKey)) {
return true;
}
return false; // Apply throttle
}
);
Headers Emitted#
Standard Headers (Always)#
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1700000000
On Throttle (429 Response)#
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700000023
IETF Draft Standard (Optional)#
When emitStandardRateLimit: true:
RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 23
Format: RateLimit-Reset is seconds until reset (not timestamp).
Multiple Throttle Buckets#
Use different scopes for different limits:
// Global throttle (all requests)
$preGlobal[] = new ThrottleMiddleware(
max: 1000,
window: 60,
pool: $cache,
scope: 'global'
);
// Login throttle (stricter)
MiddlewareAliases::register('throttle.login', fn() => new ThrottleMiddleware(
max: 5,
window: 300,
pool: $cache,
scope: 'login' // Separate bucket
));
Route::post('/login', /* ... */, ['middleware' => ['throttle.login']]);
Result: User can make 1000 requests/minute globally, but only 5 login attempts per 5 minutes.
Redis Backend (Recommended)#
For distributed/multi-server setups:
use Symfony\Component\Cache\Adapter\RedisAdapter;
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$cachePool = new RedisAdapter($redis, namespace: 'throttle');
new ThrottleMiddleware(
max: 60,
window: 60,
pool: $cachePool
);
Why Redis:
✅ Atomic INCR operations (no race conditions)
✅ TTL built-in (automatic cleanup)
✅ Shared across multiple app servers
Algorithm (Token Bucket)#
1. Generate key: {scope}:{identifier}:{window_start}
2. Increment counter in cache (atomic)
3. If counter <= max: allow (200)
4. If counter > max: deny (429)
5. Set TTL = window duration
Window sliding: Uses fixed windows aligned to Unix timestamps.
$windowStart = floor(time() / $window) * $window;
$key = "{$scope}:{$identifier}:{$windowStart}";
Example (60s window, max=10):
12:00:00 - 12:00:59 → Window 1 (max 10 requests)
12:01:00 - 12:01:59 → Window 2 (max 10 requests, reset)
Testing#
Unit Test#
use PHPUnit\Framework\TestCase;
class ThrottleTest extends TestCase
{
public function testAllowsWithinLimit(): void
{
$cache = new \Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter();
$throttle = new ThrottleMiddleware(max: 3, window: 60, pool: $cache);
$req = Request::fake(method: 'GET', uri: '/test');
$next = fn($r) => Response::json(['ok' => true]);
// First 3 should pass
for ($i = 0; $i < 3; $i++) {
$resp = $throttle($req, $next);
$this->assertEquals(200, $resp->getStatusCode());
}
// 4th should be throttled
$resp = $throttle($req, $next);
$this->assertEquals(429, $resp->getStatusCode());
$this->assertNotEmpty($resp->getHeaderLine('Retry-After'));
}
}
Integration Test#
#!/bin/bash
# test-throttle.sh
set -e
BASE_URL="http://localhost:8000"
ENDPOINT="/api/test"
echo "Testing throttle (limit: 5/min)..."
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 "✅ Throttled after $SUCCESS successful requests"
exit 0
fi
done
echo "❌ Expected throttle after 5 requests, but got $SUCCESS/10"
exit 1
Monitoring & Alerts#
Metrics to Track#
// Prometheus-style
throttle_requests_total{result="allowed"}
throttle_requests_total{result="denied"}
throttle_denials_by_endpoint{endpoint="/api/users"}
throttle_remaining_capacity{scope="global"}
Alert Examples#
# Alert on high throttle rate
- alert: HighThrottleRate
expr: rate(throttle_requests_total{result="denied"}[5m]) > 10
for: 5m
annotations:
summary: "High rate of throttled requests"
description: "{{ $value }} requests/sec being throttled"
# Alert on potential abuse
- alert: ThrottleAbuse
expr: sum(rate(throttle_requests_total{result="denied"}[1m])) by (client_ip) > 50
annotations:
summary: "Possible abuse from {{ $labels.client_ip }}"
Logging#
// In custom throttle wrapper
if ($throttled) {
$logger->warning('Request throttled', [
'ip' => $request->getAttribute('client_ip'),
'user_id' => $request->getAttribute('auth.user_id'),
'path' => $request->getPath(),
'remaining' => 0,
'reset' => $resetTime
]);
}
Common Patterns#
API with Tiered Limits#
// Free tier: 60/hour
// Pro tier: 1000/hour
// Enterprise: unlimited
new ThrottleMiddleware(
max: 60, // Default for free tier
window: 3600,
pool: $cache,
identifierResolver: function (Request $r): string {
$apiKey = $r->getHeaderLine('X-API-Key');
$tier = ApiKeyService::getTier($apiKey);
return match($tier) {
'pro' => 'pro:' . $apiKey,
'enterprise' => 'enterprise:' . $apiKey,
default => 'free:' . ($apiKey ?: $r->getAttribute('client_ip'))
};
},
max: function (Request $r): int {
$apiKey = $r->getHeaderLine('X-API-Key');
$tier = ApiKeyService::getTier($apiKey);
return match($tier) {
'pro' => 1000,
'enterprise' => PHP_INT_MAX, // Unlimited
default => 60
};
}
);
Gradual Backoff#
Increase penalty for repeated violations:
new ThrottleMiddleware(
max: 60,
window: 60,
pool: $cache,
onDeny: function (Request $r, int $remaining, int $reset): Response {
$key = 'violations:' . $r->getAttribute('client_ip');
$violations = Cache::increment($key, 1, ttl: 3600);
// Exponential backoff
$retryAfter = min(60 * (2 ** ($violations - 1)), 3600); // Cap at 1 hour
return Response::json([
'error' => 'Rate limit exceeded',
'retry_after' => $retryAfter,
'violations' => $violations
], 429)->withHeader('Retry-After', (string)$retryAfter);
}
);
Burst + Sustained#
Allow short bursts but enforce sustained rate:
// Burst bucket: 20 requests/second
$burstThrottle = new ThrottleMiddleware(
max: 20,
window: 1,
pool: $cache,
scope: 'burst'
);
// Sustained bucket: 1000 requests/hour
$sustainedThrottle = new ThrottleMiddleware(
max: 1000,
window: 3600,
pool: $cache,
scope: 'sustained'
);
// Apply both
$preGlobal[] = $burstThrottle;
$preGlobal[] = $sustainedThrottle;
Troubleshooting#
Issue: Throttle not working#
Symptoms: Unlimited requests allowed
Causes:
Cache not configured correctly
Identifier resolver returns different keys per request
Bypass function always returns true
Debug:
new ThrottleMiddleware(
max: 5,
window: 60,
pool: $cache,
debug: true, // If supported
identifierResolver: function (Request $r): string {
$id = $r->getAttribute('client_ip');
error_log("Throttle ID: {$id}"); // Debug
return $id;
}
);
Issue: Users throttled incorrectly#
Symptoms: User gets 429 even with low usage
Causes:
Shared IP (NAT, VPN, corporate proxy)
Multiple users on same identifier
Window alignment issue
Solutions:
Use authenticated user ID instead of IP
Increase limits for known shared IPs
Use per-user + per-IP hybrid
identifierResolver: function (Request $r): string {
$userId = $r->getAttribute('auth.user_id');
$ip = $r->getAttribute('client_ip');
// If authenticated, use user ID
if ($userId) {
return "user:{$userId}";
}
// If known shared IP, use session
if (in_array($ip, ['corporate-nat', '10.0.0.1'])) {
return "session:{$r->cookie('sess')}";
}
// Default to IP
return "ip:{$ip}";
}
Issue: Cache growing unbounded#
Symptoms: Redis/cache memory grows over time
Causes:
TTL not set correctly
Old keys not expiring
Fix: Ensure cache adapter supports TTL:
// Verify TTL is working
$item = $cache->getItem('test-ttl');
$item->set('value')->expiresAfter(60);
$cache->save($item);
// Check after 70 seconds
$exists = $cache->hasItem('test-ttl'); // Should be false
Issue: Distributed setup inconsistency#
Symptoms: Limit enforced differently across servers
Causes:
Using local cache (APCu, file) instead of shared (Redis, Memcached)
Clock skew between servers
Fix:
// Use shared cache
use Symfony\Component\Cache\Adapter\RedisAdapter;
$redis = new \Redis();
$redis->connect('shared-redis-host', 6379);
$cache = new RedisAdapter($redis);
// Ensure NTP sync on all servers
// systemctl enable ntp && systemctl start ntp
Performance Considerations#
Cache Operations Per Request#
Allowed request: 1 read + 1 write (INCR)
Denied request: 1 read
Total latency: ~1-2ms for Redis, <0.5ms for in-memory
Scaling#
Single Redis:
Can handle ~50,000 requests/sec
Good for most applications
Redis Cluster:
Shard by identifier prefix
Can handle >100,000 requests/sec
Optimization:
// Use pipelining for multiple buckets
$pipe = $redis->multi(\Redis::PIPELINE);
$pipe->incr("global:{$ip}:{$window}");
$pipe->incr("login:{$ip}:{$window}");
$results = $pipe->exec();
Security Considerations#
Distributed Denial of Service (DDoS)#
Throttle middleware helps but isn’t a complete solution:
What it does:
✅ Limits individual IPs/users
✅ Prevents application overload
✅ Protects expensive endpoints
What it doesn’t do:
❌ Block massive botnet attacks
❌ Prevent SYN floods
❌ Stop layer 3/4 attacks
Recommended Stack:
Network level: Cloudflare, AWS Shield (layer 3/4)
Edge level: WAF rules, bot detection
App level: ThrottleMiddleware (layer 7)
Rate Limit Bypass Attempts#
Attack: Attacker rotates IPs to bypass limits
Mitigations:
Require authentication for sensitive endpoints
Use CAPTCHA after N failures
Implement device fingerprinting
Block known proxy/VPN ranges for high-risk operations
new ThrottleMiddleware(
max: 5,
window: 300,
pool: $cache,
identifierResolver: function (Request $r): string {
// Combine IP + User-Agent + Accept-Language for basic fingerprint
$ip = $r->getAttribute('client_ip');
$ua = substr($r->getHeaderLine('User-Agent'), 0, 50);
$lang = $r->getHeaderLine('Accept-Language');
$fingerprint = hash('sha256', "{$ip}:{$ua}:{$lang}");
return "fp:{$fingerprint}";
}
);
Best Practices#
✅ Do#
Start conservative, loosen gradually
// Start with strict limits max: 30, window: 60 // Monitor, then increase max: 120, window: 60
Use different limits for different endpoints
Route::post('/login', middleware: ['throttle:5,300']); // Strict Route::get('/api/read', middleware: ['throttle:1000,60']); // Relaxed
Provide clear error messages
return Response::json([ 'error' => 'Rate limit exceeded', 'limit' => 60, 'window' => '1 minute', 'retry_after' => $retryAfter, 'documentation' => 'https://docs.example.com/rate-limits' ], 429);
Monitor throttle rates
// Alert if >10% of requests are throttled if ($throttledRate > 0.1) { alert('High throttle rate - consider increasing limits'); }
Document limits in API docs
## Rate Limits - Free tier: 60 requests/hour - Pro tier: 1,000 requests/hour - Enterprise: Custom limits Headers: - X-RateLimit-Limit - X-RateLimit-Remaining - X-RateLimit-Reset
❌ Don’t#
Don’t use overly strict limits
max: 1, window: 60 // ❌ Too strict, poor UX
Don’t throttle health checks
// Always bypass bypass: fn($r) => $r->getPath() === '/health'
Don’t use local cache in multi-server setup
// ❌ Each server has separate counter Cache::local('apcu') // ✅ Shared counter Cache::redis()
Don’t ignore Retry-After
// Clients should respect this Retry-After: 60
Don’t apply same limits to all endpoints
// ❌ Read and write have same limit // ✅ Different limits based on cost
Summary#
Throttle middleware is essential for:
✅ Preventing abuse and brute force
✅ Ensuring fair resource allocation
✅ Protecting against application-level DoS
✅ API billing/metering
Key configuration decisions:
Max + Window: Balance UX vs protection
Identifier: IP vs User vs Hybrid
Scope: Global vs per-endpoint buckets
Cache: Redis (distributed) vs local (single server)
Bypass: Health checks, internal IPs, privileged API keys
Golden rule: Start strict, monitor real usage, adjust limits based on data.