|
@@ -19,37 +19,90 @@ use App\Domain\Time\Clock;
|
|
|
* Refill is computed lazily on each `tryConsume()` call, so an idle bucket
|
|
* Refill is computed lazily on each `tryConsume()` call, so an idle bucket
|
|
|
* jumps from empty to full once `2/refill` seconds elapse.
|
|
* jumps from empty to full once `2/refill` seconds elapse.
|
|
|
*
|
|
*
|
|
|
- * Holds state in a PHP array on a singleton instance — fine for a single
|
|
|
|
|
- * api replica. PROGRESS.md flags multi-replica deployments as needing a
|
|
|
|
|
- * shared store.
|
|
|
|
|
|
|
+ * **Memory bound (SEC_REVIEW F28):** the bucket map is capped at
|
|
|
|
|
+ * `maxBuckets` entries (default {@see self::DEFAULT_MAX_BUCKETS}). Every
|
|
|
|
|
+ * `tryConsume()` re-inserts the touched key at the end of the PHP array,
|
|
|
|
|
+ * so insertion order tracks LRU; on overflow the oldest entries are
|
|
|
|
|
+ * dropped in batches of {@see self::EVICTION_BATCH} so the eviction cost
|
|
|
|
|
+ * amortises across requests instead of running on every call once we're
|
|
|
|
|
+ * at steady state. A dropped bucket comes back as a fresh full-capacity
|
|
|
|
|
+ * bucket on next access — equivalent to the bucket having idled long
|
|
|
|
|
+ * enough to refill, so eviction never grants an attacker more tokens
|
|
|
|
|
+ * than the configured rate would already permit. The cap protects
|
|
|
|
|
+ * long-lived FrankenPHP workers from token churn (rotated tokens, IP
|
|
|
|
|
+ * fan-out from unauthenticated traffic) that would otherwise grow
|
|
|
|
|
+ * `$buckets` without bound.
|
|
|
|
|
+ *
|
|
|
|
|
+ * **Multi-replica:** state is per-process and per-replica. SPEC §10
|
|
|
|
|
+ * documents single-replica as the supported topology for this limiter
|
|
|
|
|
+ * ("token-bucket; in-process per replica — acceptable for single-replica
|
|
|
|
|
+ * deployments"). Horizontal `api` scaling requires sticky-LB so a client
|
|
|
|
|
+ * consistently lands on the same replica's bucket; replacing the
|
|
|
|
|
+ * limiter with a shared backend (Redis / DB) is tracked separately as
|
|
|
|
|
+ * future scaling work.
|
|
|
*/
|
|
*/
|
|
|
final class RateLimiter
|
|
final class RateLimiter
|
|
|
{
|
|
{
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Default cap on distinct keys retained in memory. Two orders of
|
|
|
|
|
+ * magnitude above the realistic concurrent working set (a handful of
|
|
|
|
|
+ * authenticated tokens plus per-IP unauthenticated buckets) — well
|
|
|
|
|
+ * below a memory-pressure threshold even at one bucket per row.
|
|
|
|
|
+ */
|
|
|
|
|
+ private const DEFAULT_MAX_BUCKETS = 10_000;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * When the cap is exceeded, drop this many entries past the cap so
|
|
|
|
|
+ * eviction amortises across many calls. Must be smaller than
|
|
|
|
|
+ * `maxBuckets` so we never evict the entry we just inserted.
|
|
|
|
|
+ */
|
|
|
|
|
+ private const EVICTION_BATCH = 256;
|
|
|
|
|
+
|
|
|
/** @var array<string, array{tokens: float, updated: float}> */
|
|
/** @var array<string, array{tokens: float, updated: float}> */
|
|
|
private array $buckets = [];
|
|
private array $buckets = [];
|
|
|
|
|
|
|
|
|
|
+ private readonly int $maxBuckets;
|
|
|
|
|
+
|
|
|
public function __construct(
|
|
public function __construct(
|
|
|
private readonly Clock $clock,
|
|
private readonly Clock $clock,
|
|
|
private readonly float $refillPerSecond,
|
|
private readonly float $refillPerSecond,
|
|
|
private readonly float $capacity,
|
|
private readonly float $capacity,
|
|
|
|
|
+ ?int $maxBuckets = null,
|
|
|
) {
|
|
) {
|
|
|
|
|
+ $resolved = $maxBuckets ?? self::DEFAULT_MAX_BUCKETS;
|
|
|
|
|
+ if ($resolved <= self::EVICTION_BATCH) {
|
|
|
|
|
+ throw new \InvalidArgumentException(
|
|
|
|
|
+ 'maxBuckets must be greater than EVICTION_BATCH (' . self::EVICTION_BATCH . ')'
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ $this->maxBuckets = $resolved;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function tryConsume(string $key): bool
|
|
public function tryConsume(string $key): bool
|
|
|
{
|
|
{
|
|
|
$now = (float) $this->clock->now()->getTimestamp() + ((float) $this->clock->now()->format('u') / 1_000_000.0);
|
|
$now = (float) $this->clock->now()->getTimestamp() + ((float) $this->clock->now()->format('u') / 1_000_000.0);
|
|
|
|
|
|
|
|
- $state = $this->buckets[$key] ?? ['tokens' => $this->capacity, 'updated' => $now];
|
|
|
|
|
|
|
+ // Re-insert at end of array so PHP's insertion-order semantics
|
|
|
|
|
+ // give us LRU for free: oldest entry is at the front.
|
|
|
|
|
+ if (isset($this->buckets[$key])) {
|
|
|
|
|
+ $state = $this->buckets[$key];
|
|
|
|
|
+ unset($this->buckets[$key]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $state = ['tokens' => $this->capacity, 'updated' => $now];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
$elapsed = max(0.0, $now - $state['updated']);
|
|
$elapsed = max(0.0, $now - $state['updated']);
|
|
|
$tokens = min($this->capacity, $state['tokens'] + ($elapsed * $this->refillPerSecond));
|
|
$tokens = min($this->capacity, $state['tokens'] + ($elapsed * $this->refillPerSecond));
|
|
|
|
|
|
|
|
if ($tokens < 1.0) {
|
|
if ($tokens < 1.0) {
|
|
|
$this->buckets[$key] = ['tokens' => $tokens, 'updated' => $now];
|
|
$this->buckets[$key] = ['tokens' => $tokens, 'updated' => $now];
|
|
|
|
|
+ $this->enforceCapacity();
|
|
|
|
|
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$this->buckets[$key] = ['tokens' => $tokens - 1.0, 'updated' => $now];
|
|
$this->buckets[$key] = ['tokens' => $tokens - 1.0, 'updated' => $now];
|
|
|
|
|
+ $this->enforceCapacity();
|
|
|
|
|
|
|
|
return true;
|
|
return true;
|
|
|
}
|
|
}
|
|
@@ -59,4 +112,31 @@ final class RateLimiter
|
|
|
{
|
|
{
|
|
|
$this->buckets = [];
|
|
$this->buckets = [];
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /** Test hook: how many keys are currently tracked. */
|
|
|
|
|
+ public function bucketCount(): int
|
|
|
|
|
+ {
|
|
|
|
|
+ return count($this->buckets);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function enforceCapacity(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $size = count($this->buckets);
|
|
|
|
|
+ if ($size <= $this->maxBuckets) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Drop the oldest `(size - maxBuckets) + EVICTION_BATCH` entries.
|
|
|
|
|
+ // Adding the batch keeps us below the cap for the next batch of
|
|
|
|
|
+ // unique-key arrivals so we don't pay an eviction on every call.
|
|
|
|
|
+ $toDrop = ($size - $this->maxBuckets) + self::EVICTION_BATCH;
|
|
|
|
|
+ $i = 0;
|
|
|
|
|
+ foreach ($this->buckets as $k => $_) {
|
|
|
|
|
+ if ($i >= $toDrop) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ unset($this->buckets[$k]);
|
|
|
|
|
+ $i++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|