Coverage for src/taipanstack/utils/rate_limit.py: 100%
117 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +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 functools
11import inspect
12import math
13import threading
14import time
15from collections.abc import Awaitable, Callable
16from typing import ParamSpec, Protocol, TypeVar, cast, overload
18from taipanstack.core.result import Err, Ok, Result
20__all__ = ["RateLimitError", "RateLimiter", "rate_limit"]
22P = ParamSpec("P")
23T = TypeVar("T")
26class RateLimitError(Exception):
27 """Exception raised when a rate limit is exceeded."""
29 def __init__(self, message: str = "Rate limit exceeded") -> None:
30 """Initialize the RateLimitError.
32 Args:
33 message: The error message to display.Defaults to "Rate limit exceeded".
35 """
36 super().__init__(message)
39class RateLimiter:
40 """Token bucket rate limiter logic."""
42 def __init__(self, max_calls: int, time_window: float) -> None:
43 """Initialize the token bucket.
45 Args:
46 max_calls: The maximum number of calls allowed in the time window.
47 time_window: The time window in seconds.
49 """
50 if not math.isfinite(max_calls) or not math.isfinite(time_window):
51 raise ValueError("max_calls and time_window must be finite numbers")
52 if max_calls <= 0 or time_window <= 0:
53 raise ValueError("max_calls and time_window must be > 0.0")
54 self.capacity: float = float(max_calls)
55 self.time_window: float = float(time_window)
56 self.tokens: float = self.capacity
57 self.last_update: float = time.monotonic()
58 self._lock = threading.Lock()
60 def _is_valid_time_window(self) -> bool:
61 """Check if time window is valid."""
62 if not isinstance(self.time_window, (int, float)):
63 return False # type: ignore[unreachable]
64 return math.isfinite(self.time_window) and self.time_window > 0.0
66 def _is_valid_capacity(self) -> bool:
67 """Check if capacity is valid."""
68 if not isinstance(self.capacity, (int, float)):
69 return False # type: ignore[unreachable]
70 return math.isfinite(self.capacity) and self.capacity > 0.0
72 def _is_valid_bucket_state(self) -> bool:
73 """Check if the bucket's time window and capacity are in a valid state."""
74 return self._is_valid_time_window() and self._is_valid_capacity()
76 def _calculate_new_tokens(self, elapsed: float) -> float | None:
77 """Calculate new tokens based on elapsed time."""
78 new_tokens = elapsed * (self.capacity / self.time_window)
79 return new_tokens if math.isfinite(new_tokens) else None
81 def _apply_new_tokens(self, new_tokens: float) -> bool:
82 """Apply new tokens to the bucket."""
83 if not isinstance(self.tokens, (int, float)):
84 self.tokens = self.capacity # type: ignore[unreachable]
85 return False
86 if not isinstance(new_tokens, (int, float)):
87 return False # type: ignore[unreachable]
88 self.tokens += new_tokens
89 if not math.isfinite(self.tokens):
90 # Reset to previous state or capacity if corrupted
91 self.tokens = self.capacity
92 return False
93 self.tokens = min(self.tokens, self.capacity)
94 return True
96 def _add_tokens(self, now: float) -> bool:
97 """Calculate and add new tokens to the bucket based on elapsed time.
99 Args:
100 now: Current monotonic time.
102 Returns:
103 True if token update succeeds, False if state corruption is detected.
105 """
106 if not isinstance(self.last_update, (int, float)):
107 return False # type: ignore[unreachable]
108 raw_elapsed = now - self.last_update
109 if not math.isfinite(raw_elapsed):
110 return False
111 elapsed = max(0.0, raw_elapsed)
112 self.last_update = now
114 # Prevent state corruption or infinite elapsed time
115 if not self._is_valid_bucket_state():
116 return False
118 new_tokens = self._calculate_new_tokens(elapsed)
119 if new_tokens is None:
120 return False
122 return self._apply_new_tokens(new_tokens)
124 def _try_consume(self, tokens: float) -> bool:
125 """Attempt to consume the tokens from the bucket if available."""
126 if not isinstance(self.tokens, (int, float)):
127 return False # type: ignore[unreachable]
128 if not math.isfinite(self.tokens):
129 # Reset to capacity if state is corrupted to inf/nan
130 self.tokens = self.capacity
131 return False
132 if self.tokens >= tokens:
133 self.tokens -= tokens
134 return True
135 return False
137 def _process_consumption(self, tokens: float) -> bool:
138 """Process token consumption inside the lock."""
139 try:
140 now = time.monotonic()
141 except Exception:
142 return False
144 # Prevent time corruption from poisoning the bucket state.
145 # Only try to add tokens if time is finite.
146 if not isinstance(now, (int, float)) or (
147 math.isfinite(now) and not self._add_tokens(now)
148 ):
149 return False
151 return self._try_consume(tokens)
153 def consume(self, tokens: float = 1.0) -> bool:
154 """Try to consume tokens.
156 Args:
157 tokens: Number of tokens to consume. Defaults to 1.0.
159 Returns:
160 True if tokens were consumed (allow), False otherwise (limit exceeded).
162 """
163 if not isinstance(tokens, (int, float)) or not math.isfinite(tokens):
164 return False
165 if tokens <= 0:
166 return True
168 try:
169 with self._lock:
170 return self._process_consumption(tokens)
171 except Exception:
172 return False
175class RateLimitDecorator(Protocol):
176 """Protocol for the rate limit decorator."""
178 @overload
179 def __call__(
180 self,
181 func: Callable[P, T],
182 ) -> Callable[P, Result[T, RateLimitError]]: ...
184 @overload
185 def __call__(
186 self,
187 func: Callable[P, Awaitable[T]],
188 ) -> Callable[P, Awaitable[Result[T, RateLimitError]]]: ...
191def rate_limit(
192 max_calls: int,
193 time_window: float,
194) -> RateLimitDecorator:
195 """Decorate a function to apply rate limiting.
197 If the rate limit is exceeded, the wrapped function immediately returns
198 an ``Err(RateLimitError)``. Uses an in-memory token bucket strategy.
200 Args:
201 max_calls: Maximum function executions allowed in the defined window.
202 time_window: Time window size in seconds.
204 Returns:
205 Decorated function returning a ``Result[T, RateLimitError]``.
207 Example:
208 >>> @rate_limit(max_calls=2, time_window=1.0)
209 ... def fetch_data() -> str:
210 ... return "data"
211 >>> fetch_data()
212 Ok('data')
213 >>> fetch_data()
214 Ok('data')
215 >>> fetch_data()
216 Err(RateLimitError('Rate limit exceeded'))
218 """
220 def decorator(
221 func: Callable[P, T] | Callable[P, Awaitable[T]],
222 ) -> (
223 Callable[P, Result[T, RateLimitError]]
224 | Callable[P, Awaitable[Result[T, RateLimitError]]]
225 ):
226 limiter = RateLimiter(max_calls, time_window)
228 if inspect.iscoroutinefunction(func):
230 @functools.wraps(func)
231 async def async_wrapper(
232 *args: P.args,
233 **kwargs: P.kwargs,
234 ) -> Result[T, RateLimitError]:
235 try:
236 if not limiter.consume():
237 return Err(RateLimitError())
238 except Exception:
239 return Err(RateLimitError())
240 return Ok(await func(*args, **kwargs))
242 return async_wrapper
244 @functools.wraps(func)
245 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, RateLimitError]:
246 try:
247 if not limiter.consume():
248 return Err(RateLimitError())
249 except Exception:
250 return Err(RateLimitError())
251 func_sync = cast(Callable[P, T], func)
252 return Ok(func_sync(*args, **kwargs))
254 return wrapper
256 return cast(RateLimitDecorator, decorator)