Files
michaelschiemer/src/Framework/RateLimit/SlidingWindowTokenBucket.php
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

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;
}
}