- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
223 lines
7.3 KiB
PHP
223 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\RateLimit;
|
|
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
use App\Framework\Core\ValueObjects\Timestamp;
|
|
use App\Framework\RateLimit\TimeProvider\SystemTimeProvider;
|
|
use App\Framework\RateLimit\TimeProvider\TimeProviderInterface;
|
|
use App\Framework\SlidingWindow\SlidingWindow;
|
|
use App\Framework\SlidingWindow\SlidingWindowFactory;
|
|
|
|
/**
|
|
* Token bucket implementation using SlidingWindow for analytics and tracking
|
|
*/
|
|
final readonly class SlidingWindowTokenBucket
|
|
{
|
|
private SlidingWindow $tokenWindow;
|
|
|
|
private SlidingWindow $analyticsWindow;
|
|
|
|
public function __construct(
|
|
private string $identifier,
|
|
private int $capacity,
|
|
private int $refillRate,
|
|
SlidingWindowFactory $windowFactory,
|
|
private TimeProviderInterface $timeProvider = new SystemTimeProvider(),
|
|
private Duration $refillInterval = Duration::SECOND,
|
|
private bool $persistent = true
|
|
) {
|
|
// Window for tracking token consumption (short window for actual limiting)
|
|
$this->tokenWindow = $windowFactory->createNumericWindow(
|
|
identifier: "token_bucket:{$this->identifier}",
|
|
windowSize: Duration::fromMinutes(1), // Short window for token tracking
|
|
persistent: $this->persistent
|
|
);
|
|
|
|
// Window for analytics and threat detection (longer window)
|
|
$this->analyticsWindow = $windowFactory->createRateLimitWindow(
|
|
identifier: "token_analytics:{$this->identifier}",
|
|
windowSize: Duration::fromMinutes(5), // Longer window for pattern analysis
|
|
persistent: $this->persistent
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attempt to consume tokens
|
|
*/
|
|
public function consume(int $tokens = 1): RateLimitResult
|
|
{
|
|
$now = Timestamp::fromFloat($this->timeProvider->getCurrentTime());
|
|
|
|
// Record consumption attempt for analytics
|
|
$this->analyticsWindow->record($tokens, $now);
|
|
|
|
// Get current bucket state
|
|
$currentTokens = $this->getCurrentTokens($now);
|
|
|
|
if ($currentTokens >= $tokens) {
|
|
// Consume tokens by recording the consumption
|
|
$this->tokenWindow->record($tokens, $now);
|
|
|
|
$remainingTokens = $currentTokens - $tokens;
|
|
|
|
return RateLimitResult::allowed($this->capacity, $remainingTokens);
|
|
}
|
|
|
|
// Calculate retry after based on refill rate
|
|
$tokensNeeded = $tokens - $currentTokens;
|
|
$retryAfter = intval(ceil($tokensNeeded / $this->refillRate) * $this->refillInterval->toSeconds());
|
|
|
|
return RateLimitResult::exceeded($this->capacity, $currentTokens, $retryAfter);
|
|
}
|
|
|
|
/**
|
|
* Consume tokens with advanced analytics
|
|
*/
|
|
public function consumeWithAnalysis(int $tokens = 1, array $requestContext = []): RateLimitResult
|
|
{
|
|
$basicResult = $this->consume($tokens);
|
|
|
|
// Get analytics from the analytics window
|
|
$now = Timestamp::fromFloat($this->timeProvider->getCurrentTime());
|
|
$stats = $this->analyticsWindow->getStats($now);
|
|
|
|
$analysisData = [
|
|
'token_consumption' => [
|
|
'requested' => $tokens,
|
|
'available' => $basicResult->getCurrent(),
|
|
'capacity' => $this->capacity,
|
|
'refill_rate' => $this->refillRate,
|
|
],
|
|
'sliding_window_stats' => [
|
|
'window_start' => $stats->windowStart->toFloat(),
|
|
'window_end' => $stats->windowEnd->toFloat(),
|
|
'total_requests' => $stats->totalCount,
|
|
],
|
|
];
|
|
|
|
// Add advanced analytics if available
|
|
if (isset($stats->aggregatedData['rate_limit'])) {
|
|
$rateLimitResult = $stats->aggregatedData['rate_limit'];
|
|
if (method_exists($rateLimitResult, 'getBurstAnalysis')) {
|
|
$analysisData['burst_analysis'] = $rateLimitResult->getBurstAnalysis();
|
|
$analysisData['threat_analysis'] = $rateLimitResult->threatAnalysis;
|
|
}
|
|
}
|
|
|
|
// Enhance with request context
|
|
if (! empty($requestContext)) {
|
|
$analysisData = array_merge($analysisData, [
|
|
'client_ip' => $requestContext['client_ip'] ?? null,
|
|
'user_agent' => $requestContext['user_agent'] ?? null,
|
|
'request_path' => $requestContext['request_path'] ?? null,
|
|
]);
|
|
}
|
|
|
|
return RateLimitResult::withThreatAnalysis(
|
|
$basicResult->isAllowed(),
|
|
$basicResult->getLimit(),
|
|
$basicResult->getCurrent(),
|
|
$analysisData,
|
|
$basicResult->getRetryAfter()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check current token availability without consuming
|
|
*/
|
|
public function peek(): int
|
|
{
|
|
$now = Timestamp::fromFloat($this->timeProvider->getCurrentTime());
|
|
|
|
return $this->getCurrentTokens($now);
|
|
}
|
|
|
|
/**
|
|
* Reset the token bucket
|
|
*/
|
|
public function reset(): void
|
|
{
|
|
$this->tokenWindow->clear();
|
|
$this->analyticsWindow->clear();
|
|
}
|
|
|
|
/**
|
|
* Get bucket capacity
|
|
*/
|
|
public function getCapacity(): int
|
|
{
|
|
return $this->capacity;
|
|
}
|
|
|
|
/**
|
|
* Get refill rate (tokens per second)
|
|
*/
|
|
public function getRefillRate(): int
|
|
{
|
|
return $this->refillRate;
|
|
}
|
|
|
|
/**
|
|
* Get analytics data
|
|
*/
|
|
public function getAnalytics(): array
|
|
{
|
|
$now = Timestamp::fromFloat($this->timeProvider->getCurrentTime());
|
|
$stats = $this->analyticsWindow->getStats($now);
|
|
|
|
return [
|
|
'identifier' => $this->identifier,
|
|
'capacity' => $this->capacity,
|
|
'refill_rate' => $this->refillRate,
|
|
'current_tokens' => $this->getCurrentTokens($now),
|
|
'analytics_window' => [
|
|
'total_requests' => $stats->totalCount,
|
|
'window_start' => $stats->windowStart->toFloat(),
|
|
'window_end' => $stats->windowEnd->toFloat(),
|
|
'aggregated_data' => $stats->aggregatedData,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Calculate current available tokens based on refill rate and consumption
|
|
*/
|
|
private function getCurrentTokens(Timestamp $now): int
|
|
{
|
|
// Get consumption in the last refill period
|
|
$refillWindowSize = Duration::fromSeconds(
|
|
intval($this->capacity / $this->refillRate) + 60 // Buffer for precision
|
|
);
|
|
|
|
$tokenStats = $this->tokenWindow->getStats($now);
|
|
|
|
// Calculate tokens consumed in the refill window
|
|
$tokensConsumed = 0;
|
|
if (isset($tokenStats->aggregatedData['numeric'])) {
|
|
$numericResult = $tokenStats->aggregatedData['numeric'];
|
|
if (method_exists($numericResult, 'getSum')) {
|
|
$tokensConsumed = intval($numericResult->getSum());
|
|
}
|
|
}
|
|
|
|
// Calculate tokens refilled since the oldest consumption
|
|
$windowStart = $tokenStats->windowStart->toFloat();
|
|
$timeSinceWindowStart = $now->toFloat() - $windowStart;
|
|
$tokensRefilled = min(
|
|
$this->capacity,
|
|
intval($timeSinceWindowStart * $this->refillRate)
|
|
);
|
|
|
|
// Available tokens = capacity - consumed + refilled, capped at capacity
|
|
$availableTokens = min(
|
|
$this->capacity,
|
|
max(0, $this->capacity - $tokensConsumed + $tokensRefilled)
|
|
);
|
|
|
|
return $availableTokens;
|
|
}
|
|
}
|