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

1"""Sliding window rate limiter implementation.""" 

2 

3import asyncio 

4import bisect 

5import threading 

6import time 

7from typing import Optional 

8 

9 

10class LimiterPool: 

11 """Sliding window rate limiter with both sync and async support.""" 

12 

13 # Global lock for protecting global pool 

14 global_lock = threading.Lock() 

15 

16 # Global pool dictionary, storing different pool instances by key 

17 global_limiter_pool: dict = {} 

18 

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. 

24 

25 Uses singleton pattern to ensure one pool per key. 

26 

27 Args: 

28 key: Unique identifier for the pool 

29 rate: Max requests allowed in window 

30 window: Window duration in seconds 

31 

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] 

41 

42 def __init__(self, rate: int, window: float, clock=time.monotonic): 

43 """Initialize sliding window rate limiter. 

44 

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() 

63 

64 def allow(self, timeout: Optional[float] = None) -> bool: 

65 """Synchronously acquire a token. 

66 

67 Uses threading.Condition to efficiently wait for token availability 

68 instead of busy-waiting with time.sleep. 

69 

70 Args: 

71 timeout: Optional timeout in seconds 

72 

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) 

87 

88 async def async_allow(self, timeout: Optional[float] = None) -> bool: 

89 """Asynchronously acquire a token. 

90 

91 Args: 

92 timeout: Optional timeout in seconds 

93 

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) 

107 

108 def _check_and_add(self) -> bool: 

109 """Check if window is full and add timestamp if not. 

110 

111 Must be called while holding lock. 

112 

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 

126 

127 def _sleep_time(self) -> float: 

128 """Calculate time to wait until earliest record expires. 

129 

130 Must be called while holding lock. 

131 

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())