Coverage for src/taipanstack/utils/rate_limit.py: 100%
181 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
1"""
2Rate limiting utilities.
4Provides an in-memory token-bucket based rate limiting decorator
5for both synchronous and asynchronous functions. The decorator
6returns a ``Result`` type encapsulating the original return value
7or a ``RateLimitError`` error.
8"""
10import contextlib
11import functools
12import inspect
13import math
14import threading
15import time
16from collections.abc import Awaitable, Callable
17from typing import ParamSpec, Protocol, TypeVar, cast, overload
19from taipanstack.core.result import Err, Ok, Result
21__all__ = ["RateLimitError", "RateLimiter", "rate_limit"]
23P = ParamSpec("P")
24T = TypeVar("T")
27class RateLimitError(Exception):
28 """Exception raised when a rate limit is exceeded."""
30 def __init__(self, message: str = "Rate limit exceeded") -> None:
31 """Initialize the RateLimitError.
33 Args:
34 message: The error message to display.Defaults to "Rate limit exceeded".
36 """
37 super().__init__(message)
40class RateLimiter:
41 """Token bucket rate limiter logic."""
43 def _is_finite_number(self, val: float | int) -> bool:
44 return isinstance(val, (int, float)) and math.isfinite(val)
46 def _validate_finite(self, max_calls: int, time_window: float) -> None:
47 """Check if parameters are finite numbers."""
48 if not self._is_finite_number(max_calls) or not self._is_finite_number(
49 time_window
50 ):
51 raise ValueError("max_calls and time_window must be finite numbers")
53 def _validate_positive(self, max_calls: int, time_window: float) -> None:
54 """Check if parameters are positive."""
55 if max_calls <= 0:
56 raise ValueError("max_calls and time_window must be > 0.0")
57 if time_window <= 0:
58 raise ValueError("max_calls and time_window must be > 0.0")
60 def _validate_init_params(self, max_calls: int, time_window: float) -> None:
61 """Validate initialization parameters."""
62 self._validate_finite(max_calls, time_window)
63 self._validate_positive(max_calls, time_window)
65 def __init__(self, max_calls: int, time_window: float) -> None:
66 """Initialize the token bucket.
68 Args:
69 max_calls: The maximum number of calls allowed in the time window.
70 time_window: The time window in seconds.
72 """
73 self._validate_init_params(max_calls, time_window)
74 self.capacity: float = float(max_calls)
75 self.time_window: float = float(time_window)
76 self.tokens: float = self.capacity
77 self.last_update: float = time.monotonic()
78 self._lock = threading.Lock()
80 def _is_valid_time_window(self) -> bool:
81 """Check if time window is valid."""
82 if not isinstance(self.time_window, (int, float)):
83 return False # type: ignore[unreachable]
84 return math.isfinite(self.time_window) and self.time_window > 0.0
86 def _is_valid_capacity(self) -> bool:
87 """Check if capacity is valid."""
88 if not isinstance(self.capacity, (int, float)):
89 return False # type: ignore[unreachable]
90 return math.isfinite(self.capacity) and self.capacity > 0.0
92 def _is_valid_bucket_state(self) -> bool:
93 """Check if the bucket's time window and capacity are in a valid state."""
94 return self._is_valid_time_window() and self._is_valid_capacity()
96 def _calculate_new_tokens(self, elapsed: float) -> float | None:
97 """Calculate new tokens based on elapsed time."""
98 try:
99 new_tokens = elapsed * (self.capacity / self.time_window)
100 return new_tokens if math.isfinite(new_tokens) else None
101 except Exception:
102 return None
104 def _validate_token_types(self, new_tokens: float) -> bool:
105 """Validate the types of tokens."""
106 if not isinstance(self.tokens, (int, float)):
107 self.tokens = self.capacity # type: ignore[unreachable]
108 return False
109 return isinstance(new_tokens, (int, float))
111 def _update_tokens_value(self, new_tokens: float) -> bool:
112 """Update tokens value safely."""
113 try:
114 self.tokens += new_tokens
115 if not math.isfinite(self.tokens):
116 self.tokens = self.capacity
117 return False
118 self.tokens = min(self.tokens, self.capacity)
119 return True
120 except Exception:
121 self.tokens = self.capacity
122 return False
124 def _apply_new_tokens(self, new_tokens: float) -> bool:
125 """Apply new tokens to the bucket."""
126 if not self._validate_token_types(new_tokens):
127 return False
128 return self._update_tokens_value(new_tokens)
130 def _calculate_elapsed(self, now: float) -> float | None:
131 """Calculate the elapsed time since the last update."""
132 if not isinstance(self.last_update, (int, float)):
133 return None # type: ignore[unreachable]
134 try:
135 raw_elapsed = now - self.last_update
136 if not math.isfinite(raw_elapsed):
137 return None
138 return max(0.0, raw_elapsed)
139 except Exception:
140 return None
142 def _add_tokens(self, now: float) -> bool:
143 """Calculate and add new tokens to the bucket based on elapsed time.
145 Args:
146 now: Current monotonic time.
148 Returns:
149 True if token update succeeds, False if state corruption is detected.
151 """
152 elapsed = self._calculate_elapsed(now)
153 if elapsed is None:
154 return False
156 # Prevent state corruption or infinite elapsed time
157 if not self._is_valid_bucket_state():
158 return False
160 new_tokens = self._calculate_new_tokens(elapsed)
161 if new_tokens is None:
162 return False
164 if not self._apply_new_tokens(new_tokens):
165 return False
167 self.last_update = now
168 return True
170 def _check_tokens_validity(self) -> bool:
171 """Check tokens validity."""
172 if not isinstance(self.tokens, (int, float)):
173 return False # type: ignore[unreachable]
174 if not math.isfinite(self.tokens):
175 self.tokens = self.capacity
176 return False
177 return True
179 def _try_consume(self, tokens: float) -> bool:
180 """Attempt to consume the tokens from the bucket if available."""
181 try:
182 if not self._check_tokens_validity():
183 return False
184 if self.tokens >= tokens:
185 self.tokens -= tokens
186 return True
187 return False
188 except Exception:
189 self.tokens = self.capacity
190 return False
192 def _get_current_time(self) -> float | None:
193 """Get current monotonic time safely."""
194 try:
195 return time.monotonic()
196 except Exception:
197 return None
199 def _validate_and_add_tokens(self, now: float | None) -> bool:
200 """Validate time and add tokens.
202 Returns True if tokens were added successfully, False otherwise.
203 """
204 if now is None:
205 return False
206 if not isinstance(now, (int, float)):
207 return False # type: ignore[unreachable]
208 if not math.isfinite(now):
209 # If not finite, we can't add tokens, but we shouldn't fail
210 # the consumption if there are already tokens.
211 return True
212 return self._add_tokens(now)
214 def _process_consumption(self, tokens: float) -> bool:
215 """Process token consumption inside the lock."""
216 now = self._get_current_time()
218 # Prevent time corruption from poisoning the bucket state.
219 # Only try to add tokens if time is finite.
220 if not self._validate_and_add_tokens(now):
221 return False
223 return self._try_consume(tokens)
225 def _is_valid_token_amount(self, tokens: float) -> bool:
226 """Check if requested token amount is valid."""
227 if not isinstance(tokens, (int, float)):
228 return False # type: ignore[unreachable]
229 return math.isfinite(tokens)
231 def _acquire_lock_safe(self) -> bool:
232 """Safely acquire lock."""
233 try:
234 return self._lock.acquire(timeout=0.1)
235 except Exception:
236 return False
238 def consume(self, tokens: float = 1.0) -> bool:
239 """Try to consume tokens.
241 Args:
242 tokens: Number of tokens to consume. Defaults to 1.0.
244 Returns:
245 True if tokens were consumed (allow), False otherwise (limit exceeded).
247 """
248 if not self._is_valid_token_amount(tokens):
249 return False
250 if tokens <= 0:
251 return True
253 if not self._acquire_lock_safe():
254 return False
255 try:
256 return self._process_consumption(tokens)
257 finally:
258 with contextlib.suppress(RuntimeError):
259 self._lock.release()
262class RateLimitDecorator(Protocol):
263 """Protocol for the rate limit decorator."""
265 @overload
266 def __call__(
267 self,
268 func: Callable[P, T],
269 ) -> Callable[P, Result[T, RateLimitError]]: ...
271 @overload
272 def __call__(
273 self,
274 func: Callable[P, Awaitable[T]],
275 ) -> Callable[P, Awaitable[Result[T, RateLimitError]]]: ...
278def _async_rate_limit_wrapper(
279 func_coro: Callable[P, Awaitable[T]],
280 limiter: RateLimiter,
281) -> Callable[P, Awaitable[Result[T, RateLimitError]]]:
282 @functools.wraps(func_coro)
283 async def async_wrapper(
284 *args: P.args,
285 **kwargs: P.kwargs,
286 ) -> Result[T, RateLimitError]:
287 try:
288 if not limiter.consume():
289 return Err(RateLimitError())
290 except Exception:
291 return Err(RateLimitError())
292 return Ok(await func_coro(*args, **kwargs))
294 return async_wrapper # type: ignore[misc]
297def _sync_rate_limit_wrapper(
298 func_sync: Callable[P, T],
299 limiter: RateLimiter,
300) -> Callable[P, Result[T, RateLimitError]]:
301 @functools.wraps(func_sync)
302 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, RateLimitError]:
303 try:
304 if not limiter.consume():
305 return Err(RateLimitError())
306 except Exception:
307 return Err(RateLimitError())
308 return Ok(func_sync(*args, **kwargs))
310 return wrapper
313def rate_limit(
314 max_calls: int,
315 time_window: float,
316) -> RateLimitDecorator:
317 """Decorate a function to apply rate limiting.
319 If the rate limit is exceeded, the wrapped function immediately returns
320 an ``Err(RateLimitError)``. Uses an in-memory token bucket strategy.
322 Args:
323 max_calls: Maximum function executions allowed in the defined window.
324 time_window: Time window size in seconds.
326 Returns:
327 Decorated function returning a ``Result[T, RateLimitError]``.
329 Example:
330 >>> @rate_limit(max_calls=2, time_window=1.0)
331 ... def fetch_data() -> str:
332 ... return "data"
333 >>> fetch_data()
334 Ok('data')
335 >>> fetch_data()
336 Ok('data')
337 >>> fetch_data()
338 Err(RateLimitError('Rate limit exceeded'))
340 """
342 def decorator(
343 func: Callable[P, T] | Callable[P, Awaitable[T]],
344 ) -> (
345 Callable[P, Result[T, RateLimitError]]
346 | Callable[P, Awaitable[Result[T, RateLimitError]]]
347 ):
348 limiter = RateLimiter(max_calls, time_window)
350 if inspect.iscoroutinefunction(func):
351 func_coro = cast(Callable[P, Awaitable[T]], func)
352 return _async_rate_limit_wrapper(func_coro, limiter)
354 func_sync = cast(Callable[P, T], func)
355 return _sync_rate_limit_wrapper(func_sync, limiter)
357 return cast(RateLimitDecorator, decorator)