php-api/src/Mod/Api/RateLimit/RateLimitService.php
2026-01-26 20:57:08 +00:00

247 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Api\RateLimit;
use Carbon\Carbon;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Rate limiting service with sliding window algorithm.
*
* Provides granular rate limiting with support for:
* - Per-key rate limiting (API keys, users, IPs, etc.)
* - Sliding window algorithm for smoother rate limiting
* - Burst allowance configuration
* - Tier-based limits
*/
class RateLimitService
{
/**
* Cache prefix for rate limit keys.
*/
protected const CACHE_PREFIX = 'rate_limit:';
public function __construct(
protected CacheRepository $cache,
) {}
/**
* Check if a request would be allowed without incrementing the counter.
*
* @param string $key Unique identifier for the rate limit bucket
* @param int $limit Maximum requests allowed
* @param int $window Time window in seconds
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
*/
public function check(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = (int) floor($limit * $burst);
$now = Carbon::now();
$windowStart = $now->timestamp - $window;
// Get current window data
$hits = $this->getWindowHits($cacheKey, $windowStart);
$currentCount = count($hits);
$remaining = max(0, $effectiveLimit - $currentCount);
// Calculate reset time
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
if ($currentCount >= $effectiveLimit) {
// Find oldest hit to determine retry after
$oldestHit = min($hits);
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
}
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
}
/**
* Record a hit and check if the request is allowed.
*
* @param string $key Unique identifier for the rate limit bucket
* @param int $limit Maximum requests allowed
* @param int $window Time window in seconds
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
*/
public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = (int) floor($limit * $burst);
$now = Carbon::now();
$windowStart = $now->timestamp - $window;
// Get current window data and clean up old entries
$hits = $this->getWindowHits($cacheKey, $windowStart);
$currentCount = count($hits);
// Calculate reset time
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
if ($currentCount >= $effectiveLimit) {
// Find oldest hit to determine retry after
$oldestHit = min($hits);
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
}
// Record the hit
$hits[] = $now->timestamp;
$this->storeWindowHits($cacheKey, $hits, $window);
$remaining = max(0, $effectiveLimit - count($hits));
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
}
/**
* Get remaining attempts for a key.
*
* @param string $key Unique identifier for the rate limit bucket
* @param int $limit Maximum requests allowed (needed to calculate remaining)
* @param int $window Time window in seconds
* @param float $burst Burst multiplier
*/
public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = (int) floor($limit * $burst);
$windowStart = Carbon::now()->timestamp - $window;
$hits = $this->getWindowHits($cacheKey, $windowStart);
return max(0, $effectiveLimit - count($hits));
}
/**
* Reset (clear) a rate limit bucket.
*/
public function reset(string $key): void
{
$cacheKey = $this->getCacheKey($key);
$this->cache->forget($cacheKey);
}
/**
* Get the current hit count for a key.
*/
public function attempts(string $key, int $window): int
{
$cacheKey = $this->getCacheKey($key);
$windowStart = Carbon::now()->timestamp - $window;
return count($this->getWindowHits($cacheKey, $windowStart));
}
/**
* Build a rate limit key for an endpoint.
*/
public function buildEndpointKey(string $identifier, string $endpoint): string
{
return "endpoint:{$identifier}:{$endpoint}";
}
/**
* Build a rate limit key for a workspace.
*/
public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string
{
$key = "workspace:{$workspaceId}";
if ($suffix !== null) {
$key .= ":{$suffix}";
}
return $key;
}
/**
* Build a rate limit key for an API key.
*/
public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string
{
$key = "api_key:{$apiKeyId}";
if ($suffix !== null) {
$key .= ":{$suffix}";
}
return $key;
}
/**
* Build a rate limit key for an IP address.
*/
public function buildIpKey(string $ip, ?string $suffix = null): string
{
$key = "ip:{$ip}";
if ($suffix !== null) {
$key .= ":{$suffix}";
}
return $key;
}
/**
* Get hits within the sliding window.
*
* @return array<int> Array of timestamps
*/
protected function getWindowHits(string $cacheKey, int $windowStart): array
{
/** @var array<int> $hits */
$hits = $this->cache->get($cacheKey, []);
// Filter to only include hits within the window
return array_values(array_filter($hits, fn (int $timestamp) => $timestamp >= $windowStart));
}
/**
* Store hits in cache.
*
* @param array<int> $hits Array of timestamps
*/
protected function storeWindowHits(string $cacheKey, array $hits, int $window): void
{
// Add buffer to TTL to handle clock drift
$ttl = $window + 60;
$this->cache->put($cacheKey, $hits, $ttl);
}
/**
* Calculate when the rate limit resets.
*
* @param array<int> $hits Array of timestamps
*/
protected function calculateResetTime(array $hits, int $window, int $limit): Carbon
{
if (empty($hits)) {
return Carbon::now()->addSeconds($window);
}
// If under limit, reset is at the end of the window
if (count($hits) < $limit) {
return Carbon::now()->addSeconds($window);
}
// If at or over limit, reset when the oldest hit expires
$oldestHit = min($hits);
return Carbon::createFromTimestamp($oldestHit + $window);
}
/**
* Generate the cache key.
*/
protected function getCacheKey(string $key): string
{
return self::CACHE_PREFIX.$key;
}
}