84 lines
2.5 KiB
PHP
84 lines
2.5 KiB
PHP
<?php
|
|
|
|
namespace App\Middleware;
|
|
|
|
use App\Core\Request;
|
|
use App\Core\Response;
|
|
use Predis\Client as RedisClient;
|
|
use Exception;
|
|
use Throwable;
|
|
|
|
class RateLimit implements MiddlewareInterface
|
|
{
|
|
private ?RedisClient $redis = null;
|
|
private int $limit = 100; // Allow 100 requests
|
|
private int $window = 60; // Per 60 seconds
|
|
|
|
public function __construct()
|
|
{
|
|
$config = require __DIR__ . '/../../config/redis.php';
|
|
if (!empty($config['host'])) {
|
|
try {
|
|
$this->redis = new RedisClient([
|
|
'scheme' => 'tcp',
|
|
'host' => $config['host'],
|
|
'port' => $config['port'],
|
|
'password' => $config['password'],
|
|
'timeout' => 0.5, // 500ms connection timeout to fail fast
|
|
]);
|
|
$this->redis->connect();
|
|
} catch (Throwable $e) {
|
|
// Degrade gracefully if Redis server is down
|
|
$this->redis = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle rate limiting logic.
|
|
*/
|
|
public function handle(Request $request, Response $response, callable $next): void
|
|
{
|
|
if ($this->redis === null) {
|
|
// Redis unavailable, skip throttle check to avoid service outage
|
|
$next();
|
|
return;
|
|
}
|
|
|
|
$ip = $request->getIp();
|
|
$path = $request->getPath();
|
|
$key = "rate_limit:" . md5($ip . ":" . $path);
|
|
|
|
try {
|
|
$current = $this->redis->get($key);
|
|
|
|
if ($current !== null && (int)$current >= $this->limit) {
|
|
$ttl = $this->redis->ttl($key);
|
|
$response->header('Retry-After', (string)max(1, $ttl));
|
|
throw new Exception("Too Many Requests. Rate limit exceeded.", 429);
|
|
}
|
|
|
|
if ($current === null) {
|
|
// First request in the time frame window
|
|
$this->redis->setex($key, $this->window, 1);
|
|
$current = 0;
|
|
} else {
|
|
$this->redis->incr($key);
|
|
}
|
|
|
|
// Set rate limit headers
|
|
$remaining = $this->limit - ((int)$current + 1);
|
|
$response->header('X-RateLimit-Limit', (string)$this->limit);
|
|
$response->header('X-RateLimit-Remaining', (string)max(0, $remaining));
|
|
|
|
} catch (Throwable $e) {
|
|
if ($e->getCode() === 429) {
|
|
throw $e;
|
|
}
|
|
// Logging or catching connection dropping mid-request
|
|
}
|
|
|
|
$next();
|
|
}
|
|
}
|