Coverage for agentos/core/rate_limiter.py: 0%
168 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-05 22:09 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-05 22:09 +0800
1"""AgentOS Rate Limiter — multi-strategy throttling for production APIs.
3Strategies:
4- TokenBucket: classic token refill, burst-tolerant
5- SlidingWindow: precise time-window counting
6- ConcurrentLimiter: limit in-flight requests (semaphore-based)
7- CompositeLimiter: chain multiple limiters together
9Design: ~280 lines, zero external deps beyond stdlib + asyncio.
10"""
12from __future__ import annotations
14import asyncio
15import logging
16import time
17from dataclasses import dataclass
18from enum import Enum
19from typing import Awaitable, Callable, Dict, Optional, TypeVar
21logger = logging.getLogger(__name__)
23T = TypeVar("T")
26# ============================================================================
27# Core limiters
28# ============================================================================
30class TokenBucket:
31 """Classic token bucket for rate limiting with burst support.
33 Tokens refill at a fixed rate up to capacity. Each request costs 1 token.
34 """
36 def __init__(self, rate: float, capacity: int):
37 """
38 Args:
39 rate: Tokens per second refill rate
40 capacity: Maximum tokens (burst size)
41 """
42 if rate <= 0:
43 raise ValueError("rate must be > 0")
44 if capacity <= 0:
45 raise ValueError("capacity must be > 0")
47 self.rate = rate
48 self.capacity = capacity
49 self._tokens = float(capacity)
50 self._last_refill = time.monotonic()
51 self._lock = asyncio.Lock()
53 def _refill(self) -> None:
54 now = time.monotonic()
55 elapsed = now - self._last_refill
56 self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
57 self._last_refill = now
59 async def acquire(self, tokens: int = 1) -> bool:
60 """Try to acquire tokens. Returns True if successful, False otherwise."""
61 async with self._lock:
62 self._refill()
63 if self._tokens >= tokens:
64 self._tokens -= tokens
65 return True
66 return False
68 async def wait_and_acquire(
69 self, tokens: int = 1, timeout: Optional[float] = None
70 ) -> bool:
71 """Wait until tokens are available or timeout expires."""
72 deadline = (time.monotonic() + timeout) if timeout is not None else None
74 while True:
75 if await self.acquire(tokens):
76 return True
78 async with self._lock:
79 self._refill()
80 if self._tokens >= tokens:
81 self._tokens -= tokens
82 return True
84 # Calculate wait time
85 needed = tokens - self._tokens
86 wait_time = needed / self.rate
88 if deadline is not None:
89 remaining = deadline - time.monotonic()
90 if remaining <= 0:
91 return False
92 wait_time = min(wait_time, remaining)
94 await asyncio.sleep(wait_time)
96 @property
97 def available_tokens(self) -> float:
98 return self._tokens
100 @property
101 def fill_level(self) -> float:
102 """0.0 (empty) to 1.0 (full)."""
103 return self._tokens / self.capacity
106class SlidingWindow:
107 """Sliding window rate limiter using precise timestamps.
109 Tracks request timestamps in a deque for O(1) amortized cleanup.
110 """
112 def __init__(self, max_requests: int, window_seconds: float):
113 if max_requests <= 0:
114 raise ValueError("max_requests must be > 0")
115 if window_seconds <= 0:
116 raise ValueError("window_seconds must be > 0")
118 self.max_requests = max_requests
119 self.window_seconds = window_seconds
120 self._timestamps: list[float] = []
121 self._lock = asyncio.Lock()
123 def _cleanup(self, now: float) -> None:
124 cutoff = now - self.window_seconds
125 # Find first timestamp within window
126 idx = 0
127 for ts in self._timestamps:
128 if ts >= cutoff:
129 break
130 idx += 1
131 if idx > 0:
132 self._timestamps = self._timestamps[idx:]
134 async def acquire(self) -> bool:
135 """Try to add a request to the window. Returns True if within limit."""
136 async with self._lock:
137 now = time.monotonic()
138 self._cleanup(now)
139 if len(self._timestamps) < self.max_requests:
140 self._timestamps.append(now)
141 return True
142 return False
144 @property
145 def current_count(self) -> int:
146 return len(self._timestamps)
148 @property
149 def remaining(self) -> int:
150 return max(0, self.max_requests - len(self._timestamps))
153class ConcurrentLimiter:
154 """Limit the number of in-flight concurrent operations."""
156 def __init__(self, max_concurrent: int):
157 if max_concurrent <= 0:
158 raise ValueError("max_concurrent must be > 0")
159 self._semaphore = asyncio.Semaphore(max_concurrent)
161 async def acquire(self) -> bool:
162 """Acquire a slot. Returns False if cancelled."""
163 try:
164 await self._semaphore.acquire()
165 return True
166 except asyncio.CancelledError:
167 return False
169 def release(self) -> None:
170 """Release a slot."""
171 self._semaphore.release()
173 @property
174 def available(self) -> int:
175 return self._semaphore._value # pyright: ignore[reportPrivateUsage]
178class CompositeLimiter:
179 """Chain multiple limiters — all must pass for a request to proceed."""
181 def __init__(self, limiters: list):
182 self.limiters = limiters
184 async def acquire(self) -> bool:
185 """Try to acquire from all limiters simultaneously."""
186 results = await asyncio.gather(
187 *[limiter.acquire() for limiter in self.limiters],
188 return_exceptions=True,
189 )
190 for r in results:
191 if r is False or isinstance(r, Exception):
192 return False
193 return all(results)
196# ============================================================================
197# Rate limit decorator / context manager
198# ============================================================================
200class RateLimitError(Exception):
201 """Raised when rate limit is exceeded."""
202 pass
205class RateLimiter:
206 """Unified rate limiter wrapping any strategy."""
208 def __init__(
209 self,
210 name: str = "default",
211 strategy=None,
212 ):
213 self.name = name
214 self.strategy = strategy
216 async def __aenter__(self):
217 ok = await self.strategy.acquire() if self.strategy else True
218 if not ok:
219 raise RateLimitError(f"Rate limit exceeded: {self.name}")
220 return self
222 async def __aexit__(self, *args):
223 if isinstance(self.strategy, ConcurrentLimiter):
224 self.strategy.release()
226 @classmethod
227 def token_bucket(cls, name: str, rate: float, capacity: int) -> "RateLimiter":
228 return cls(name=name, strategy=TokenBucket(rate=rate, capacity=capacity))
230 @classmethod
231 def sliding_window(
232 cls, name: str, max_requests: int, window_seconds: float
233 ) -> "RateLimiter":
234 return cls(
235 name=name,
236 strategy=SlidingWindow(
237 max_requests=max_requests, window_seconds=window_seconds
238 ),
239 )
241 @classmethod
242 def concurrent(cls, name: str, max_concurrent: int) -> "RateLimiter":
243 return cls(name=name, strategy=ConcurrentLimiter(max_concurrent=max_concurrent))
246# ============================================================================
247# Endpoint-level registry
248# ============================================================================
250@dataclass
251class EndpointRateLimit:
252 """Per-endpoint rate limit configuration."""
254 endpoint: str
255 requests_per_second: Optional[float] = None
256 requests_per_minute: Optional[int] = None
257 concurrent: Optional[int] = None
258 burst: int = 1
261class RateLimitRegistry:
262 """Manage per-endpoint rate limiters."""
264 def __init__(self):
265 self._limiters: Dict[str, RateLimiter] = {}
266 self._lock = asyncio.Lock()
268 async def configure(self, spec: EndpointRateLimit) -> RateLimiter:
269 async with self._lock:
270 limiters = []
272 if spec.requests_per_second is not None:
273 limiters.append(
274 TokenBucket(
275 rate=spec.requests_per_second,
276 capacity=max(spec.burst, 1),
277 )
278 )
280 if spec.requests_per_minute is not None:
281 limiters.append(
282 SlidingWindow(
283 max_requests=spec.requests_per_minute,
284 window_seconds=60.0,
285 )
286 )
288 if spec.concurrent is not None:
289 limiters.append(ConcurrentLimiter(max_concurrent=spec.concurrent))
291 strategy = (
292 CompositeLimiter(limiters) if len(limiters) > 1
293 else limiters[0] if limiters
294 else None
295 )
297 limiter = RateLimiter(name=spec.endpoint, strategy=strategy)
298 self._limiters[spec.endpoint] = limiter
299 return limiter
301 async def get(self, endpoint: str) -> Optional[RateLimiter]:
302 return self._limiters.get(endpoint)
304 async def acquire(self, endpoint: str) -> bool:
305 limiter = self._limiters.get(endpoint)
306 if limiter is None:
307 return True
308 return await limiter.strategy.acquire() if limiter.strategy else True