Coverage for qreward / utils / scheduler / limiter.py: 100.00%
63 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
1"""Sliding window rate limiter implementation."""
3import asyncio
4import bisect
5import threading
6import time
7from typing import Optional
10class LimiterPool:
11 """Sliding window rate limiter with both sync and async support."""
13 # Global lock for protecting global pool
14 global_lock = threading.Lock()
16 # Global pool dictionary, storing different pool instances by key
17 global_limiter_pool: dict = {}
19 @classmethod
20 def get_pool(
21 cls, key: str, rate: int, window: float
22 ) -> Optional["LimiterPool"]:
23 """Get or create a limiter pool instance for the given key.
25 Uses singleton pattern to ensure one pool per key.
27 Args:
28 key: Unique identifier for the pool
29 rate: Max requests allowed in window
30 window: Window duration in seconds
32 Returns:
33 LimiterPool instance or None if rate/window is invalid
34 """
35 if rate <= 0 or window <= 0:
36 return None
37 with cls.global_lock:
38 if key not in cls.global_limiter_pool:
39 cls.global_limiter_pool[key] = cls(rate=rate, window=window)
40 return cls.global_limiter_pool[key]
42 def __init__(self, rate: int, window: float, clock=time.monotonic):
43 """Initialize sliding window rate limiter.
45 Args:
46 rate: Max requests allowed in window
47 window: Window duration in seconds
48 clock: Time function, defaults to time.monotonic
49 """
50 if rate <= 0 or window <= 0:
51 raise ValueError("rate / window must be positive")
52 self.rate = rate
53 self.window = float(window)
54 self._clock = clock
55 # Timestamp list, monotonically increasing
56 self._times: list[float] = []
57 # Condition variable for efficient sync waiting (replaces busy-wait)
58 self._condition = threading.Condition(threading.RLock())
59 # Alias for backward compatibility — the underlying lock
60 self._lock = self._condition
61 # Async lock
62 self._aio_lock = asyncio.Lock()
64 def allow(self, timeout: Optional[float] = None) -> bool:
65 """Synchronously acquire a token.
67 Uses threading.Condition to efficiently wait for token availability
68 instead of busy-waiting with time.sleep.
70 Args:
71 timeout: Optional timeout in seconds
73 Returns:
74 True if token acquired, False if timeout
75 """
76 deadline = None if timeout is None else self._clock() + timeout
77 with self._condition:
78 while True:
79 ok = self._check_and_add()
80 if ok:
81 self._condition.notify_all()
82 return True
83 sleep_t = self._sleep_time()
84 if deadline and self._clock() + sleep_t > deadline:
85 return False
86 self._condition.wait(timeout=sleep_t)
88 async def async_allow(self, timeout: Optional[float] = None) -> bool:
89 """Asynchronously acquire a token.
91 Args:
92 timeout: Optional timeout in seconds
94 Returns:
95 True if token acquired, False if timeout
96 """
97 deadline = None if timeout is None else self._clock() + timeout
98 while True:
99 async with self._aio_lock:
100 ok = self._check_and_add()
101 if ok:
102 return True
103 sleep_t = self._sleep_time()
104 if deadline and self._clock() + sleep_t > deadline:
105 return False
106 await asyncio.sleep(sleep_t)
108 def _check_and_add(self) -> bool:
109 """Check if window is full and add timestamp if not.
111 Must be called while holding lock.
113 Returns:
114 True if request allowed, False if window full
115 """
116 now = self._clock()
117 cutoff = now - self.window
118 # Clean expired timestamps
119 idx = bisect.bisect_left(self._times, cutoff)
120 self._times = self._times[idx:]
121 # Check if window is full
122 if len(self._times) < self.rate:
123 bisect.insort(self._times, now)
124 return True
125 return False
127 def _sleep_time(self) -> float:
128 """Calculate time to wait until earliest record expires.
130 Must be called while holding lock.
132 Returns:
133 Sleep time in seconds
134 """
135 if not self._times:
136 return 0.01
137 earliest = self._times[0]
138 return max(0.0, earliest + self.window - self._clock())