from __future__ import annotations import threading import time import unittest from collections.abc import Callable class TokenBucket: """Thread-safe token-bucket rate limiter.""" def __init__( self, capacity: float, refill_rate: float, clock: Callable[[], float] = time.monotonic, ) -> None: if capacity <= 0: raise ValueError("capacity must be positive") if refill_rate < 0: raise ValueError("refill_rate must be non-negative") self.capacity = float(capacity) self.refill_rate = float(refill_rate) self._clock = clock self._tokens = float(capacity) self._updated_at = clock() self._lock = threading.Lock() def _refill(self) -> None: now = self._clock() elapsed = max(0.0, now - self._updated_at) self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_rate) self._updated_at = now def allow(self, tokens: float = 1.0) -> bool: """Return True and spend tokens if the request can proceed now.""" if tokens <= 0: raise ValueError("tokens must be positive") if tokens > self.capacity: return False with self._lock: self._refill() if self._tokens >= tokens: self._tokens -= tokens return True return False def wait_time(self, tokens: float = 1.0) -> float: """Return seconds until tokens are available, or infinity if impossible.""" if tokens <= 0: raise ValueError("tokens must be positive") if tokens > self.capacity: return float("inf") with self._lock: self._refill() missing = tokens - self._tokens if missing <= 0: return 0.0 if self.refill_rate == 0: return float("inf") return missing / self.refill_rate class FakeClock: def __init__(self) -> None: self.now = 0.0 def __call__(self) -> float: return self.now def advance(self, seconds: float) -> None: self.now += seconds class TokenBucketTests(unittest.TestCase): def test_allows_until_empty(self) -> None: clock = FakeClock() bucket = TokenBucket(capacity=2, refill_rate=1, clock=clock) self.assertTrue(bucket.allow()) self.assertTrue(bucket.allow()) self.assertFalse(bucket.allow()) def test_refills_over_time(self) -> None: clock = FakeClock() bucket = TokenBucket(capacity=2, refill_rate=2, clock=clock) self.assertTrue(bucket.allow(2)) self.assertFalse(bucket.allow()) self.assertAlmostEqual(bucket.wait_time(), 0.5) clock.advance(0.5) self.assertTrue(bucket.allow()) self.assertFalse(bucket.allow()) clock.advance(10) self.assertTrue(bucket.allow(2)) if __name__ == "__main__": unittest.main()