agent/php/Mod/Api/RateLimit/RateLimitService.php
Snider 5385385314 feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
Foundation slice for Mantis #844 php/Mod/Api RFC implementation:

* New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs,
  Middleware, Models, RateLimit, Routes, Services
* Models: ApiKey, WebhookEndpoint, WebhookDelivery
* WebhookService::dispatch() with DB::transaction + afterCommit
* DeliverWebhookJob with retry/backoff
* WebhookSignature with timing-safe verification + 5-minute tolerance +
  dual-secret rotation support
* Sliding-window rate limiter in RateLimit/RateLimitService.php
* AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback
* DocsController / DocumentationController split
* 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries
* Foundation tests under php/tests/Feature/Mod/Api/
* FOLLOWUP.md tracks remaining RFC scope

php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=844
2026-04-25 21:01:54 +01:00

296 lines
8.7 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mod\Api\RateLimit;
use Carbon\Carbon;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use InvalidArgumentException;
class RateLimitService
{
protected const CACHE_PREFIX = 'rate_limit:';
public function __construct(
protected CacheRepository $cache,
) {}
public function checkLimit(mixed $apiKey = null, string $endpoint = 'global', ?RateLimit $rateLimit = null): RateLimitResult
{
$rateLimit ??= $this->defaultRateLimit();
return $this->check(
key: $this->resolveLimitKey($apiKey, $endpoint, $rateLimit),
limit: $rateLimit->limit,
window: $rateLimit->window,
burst: $rateLimit->burst,
);
}
public function recordHit(mixed $apiKey = null, string $endpoint = 'global', ?RateLimit $rateLimit = null): RateLimitResult
{
$rateLimit ??= $this->defaultRateLimit();
return $this->hit(
key: $this->resolveLimitKey($apiKey, $endpoint, $rateLimit),
limit: $rateLimit->limit,
window: $rateLimit->window,
burst: $rateLimit->burst,
);
}
public function resetFor(mixed $apiKey = null, string $endpoint = 'global', ?RateLimit $rateLimit = null): void
{
$rateLimit ??= $this->defaultRateLimit();
$this->reset($this->resolveLimitKey($apiKey, $endpoint, $rateLimit));
}
public function check(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = $this->effectiveLimit($limit, $burst);
$this->guardWindow($window);
$now = Carbon::now();
$windowStart = $now->timestamp - $window;
$hits = $this->getWindowHits($cacheKey, $windowStart);
$currentCount = count($hits);
$remaining = max(0, $effectiveLimit - $currentCount);
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
if ($currentCount >= $effectiveLimit) {
$oldestHit = min($hits);
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
}
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
}
public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$effectiveLimit = $this->effectiveLimit($limit, $burst);
$this->guardWindow($window);
$now = Carbon::now();
$windowStart = $now->timestamp - $window;
$hits = $this->getWindowHits($cacheKey, $windowStart);
if (count($hits) >= $effectiveLimit) {
$oldestHit = min($hits);
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
return RateLimitResult::denied(
$limit,
$retryAfter,
$this->calculateResetTime($hits, $window, $effectiveLimit),
);
}
$hits[] = $now->timestamp;
$this->storeWindowHits($cacheKey, $hits, $window);
return RateLimitResult::allowed(
$limit,
max(0, $effectiveLimit - count($hits)),
$this->calculateResetTime($hits, $window, $effectiveLimit),
);
}
public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int
{
$this->guardWindow($window);
return max(
0,
$this->effectiveLimit($limit, $burst)
- count($this->getWindowHits($this->getCacheKey($key), Carbon::now()->timestamp - $window)),
);
}
public function reset(string $key): void
{
$this->cache->forget($this->getCacheKey($key));
}
public function attempts(string $key, int $window): int
{
$this->guardWindow($window);
return count($this->getWindowHits($this->getCacheKey($key), Carbon::now()->timestamp - $window));
}
public function buildEndpointKey(string $identifier, string $endpoint): string
{
return "endpoint:{$identifier}:{$endpoint}";
}
public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string
{
return $suffix === null
? "workspace:{$workspaceId}"
: "workspace:{$workspaceId}:{$suffix}";
}
public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string
{
return $suffix === null
? "api_key:{$apiKeyId}"
: "api_key:{$apiKeyId}:{$suffix}";
}
public function buildIpKey(string $ip, ?string $suffix = null): string
{
return $suffix === null
? "ip:{$ip}"
: "ip:{$ip}:{$suffix}";
}
/**
* @return array<int, int>
*/
protected function getWindowHits(string $cacheKey, int $windowStart): array
{
$hits = $this->cache->get($cacheKey, []);
if (! is_array($hits)) {
return [];
}
$windowHits = [];
foreach ($hits as $timestamp) {
if (! is_int($timestamp) && ! is_float($timestamp) && ! is_string($timestamp)) {
continue;
}
$timestamp = (int) $timestamp;
if ($timestamp >= $windowStart) {
$windowHits[] = $timestamp;
}
}
return $windowHits;
}
/**
* @param array<int, int> $hits
*/
protected function storeWindowHits(string $cacheKey, array $hits, int $window): void
{
$this->cache->put($cacheKey, $hits, $window + 60);
}
/**
* @param array<int, int> $hits
*/
protected function calculateResetTime(array $hits, int $window, int $limit): Carbon
{
if ($hits === [] || count($hits) < $limit) {
return Carbon::now()->addSeconds($window);
}
return Carbon::createFromTimestamp(min($hits) + $window);
}
protected function getCacheKey(string $key): string
{
return self::CACHE_PREFIX.$key;
}
protected function resolveLimitKey(mixed $apiKey, string $endpoint, RateLimit $rateLimit): string
{
if ($rateLimit->key !== '') {
return $rateLimit->key;
}
return $this->buildEndpointKey(
$this->normaliseApiKey($apiKey),
$this->normaliseEndpoint($endpoint),
);
}
protected function defaultRateLimit(): RateLimit
{
$config = config('api.rate_limiting', []);
if (! is_array($config) || $config === []) {
return new RateLimit(limit: 60, window: 60, burst: 1.0);
}
return new RateLimit(
limit: (int) ($config['default_limit'] ?? $config['limit'] ?? 60),
window: (int) ($config['default_window'] ?? $config['window'] ?? 60),
burst: (float) ($config['default_burst'] ?? $config['burst'] ?? 1.0),
);
}
protected function effectiveLimit(int $limit, float $burst): int
{
if ($limit < 1) {
throw new InvalidArgumentException('Rate limit must be at least 1.');
}
if ($burst <= 0) {
throw new InvalidArgumentException('Rate limit burst must be greater than zero.');
}
return max(1, (int) floor($limit * $burst));
}
protected function guardWindow(int $window): void
{
if ($window < 1) {
throw new InvalidArgumentException('Rate limit window must be at least 1 second.');
}
}
protected function normaliseApiKey(mixed $apiKey): string
{
if ($apiKey === null || $apiKey === '') {
return 'api_key:anonymous';
}
if (is_int($apiKey) || is_string($apiKey)) {
$identifier = (string) $apiKey;
return str_contains($identifier, ':') ? $identifier : "api_key:{$identifier}";
}
if (is_object($apiKey)) {
if (method_exists($apiKey, 'getKey')) {
$key = $apiKey->getKey();
if (is_int($key) || is_string($key)) {
return "api_key:{$key}";
}
}
if (isset($apiKey->id) && (is_int($apiKey->id) || is_string($apiKey->id))) {
return "api_key:{$apiKey->id}";
}
if (method_exists($apiKey, '__toString')) {
return (string) $apiKey;
}
return 'api_key:object:'.spl_object_id($apiKey);
}
return 'api_key:'.md5(serialize($apiKey));
}
protected function normaliseEndpoint(string $endpoint): string
{
$endpoint = trim($endpoint);
return $endpoint === '' ? 'global' : $endpoint;
}
}