Coverage for agentos/core/rate_limiter.py: 0%
167 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +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 typing import Dict, Optional, TypeVar
20logger = logging.getLogger(__name__)
22T = TypeVar("T")
25# ============================================================================
26# Core limiters
27# ============================================================================
29class TokenBucket:
30 """Classic token bucket for rate limiting with burst support.
32 Tokens refill at a fixed rate up to capacity. Each request costs 1 token.
33 """
35 def __init__(self, rate: float, capacity: int):
36 """
37 Args:
38 rate: Tokens per second refill rate
39 capacity: Maximum tokens (burst size)
40 """
41 if rate <= 0:
42 raise ValueError("rate must be > 0")
43 if capacity <= 0:
44 raise ValueError("capacity must be > 0")
46 self.rate = rate
47 self.capacity = capacity
48 self._tokens = float(capacity)
49 self._last_refill = time.monotonic()
50 self._lock = asyncio.Lock()
52 def _refill(self) -> None:
53 now = time.monotonic()
54 elapsed = now - self._last_refill
55 self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
56 self._last_refill = now
58 async def acquire(self, tokens: int = 1) -> bool:
59 """Try to acquire tokens. Returns True if successful, False otherwise."""
60 async with self._lock:
61 self._refill()
62 if self._tokens >= tokens:
63 self._tokens -= tokens
64 return True
65 return False
67 async def wait_and_acquire(
68 self, tokens: int = 1, timeout: Optional[float] = None
69 ) -> bool:
70 """Wait until tokens are available or timeout expires."""
71 deadline = (time.monotonic() + timeout) if timeout is not None else None
73 while True:
74 if await self.acquire(tokens):
75 return True
77 async with self._lock:
78 self._refill()
79 if self._tokens >= tokens:
80 self._tokens -= tokens
81 return True
83 # Calculate wait time
84 needed = tokens - self._tokens
85 wait_time = needed / self.rate
87 if deadline is not None:
88 remaining = deadline - time.monotonic()
89 if remaining <= 0:
90 return False
91 wait_time = min(wait_time, remaining)
93 await asyncio.sleep(wait_time)
95 @property
96 def available_tokens(self) -> float:
97 return self._tokens
99 @property
100 def fill_level(self) -> float:
101 """0.0 (empty) to 1.0 (full)."""
102 return self._tokens / self.capacity
105class SlidingWindow:
106 """Sliding window rate limiter using precise timestamps.
108 Tracks request timestamps in a deque for O(1) amortized cleanup.
109 """
111 def __init__(self, max_requests: int, window_seconds: float):
112 if max_requests <= 0:
113 raise ValueError("max_requests must be > 0")
114 if window_seconds <= 0:
115 raise ValueError("window_seconds must be > 0")
117 self.max_requests = max_requests
118 self.window_seconds = window_seconds
119 self._timestamps: list[float] = []
120 self._lock = asyncio.Lock()
122 def _cleanup(self, now: float) -> None:
123 cutoff = now - self.window_seconds
124 # Find first timestamp within window
125 idx = 0
126 for ts in self._timestamps:
127 if ts >= cutoff:
128 break
129 idx += 1
130 if idx > 0:
131 self._timestamps = self._timestamps[idx:]
133 async def acquire(self) -> bool:
134 """Try to add a request to the window. Returns True if within limit."""
135 async with self._lock:
136 now = time.monotonic()
137 self._cleanup(now)
138 if len(self._timestamps) < self.max_requests:
139 self._timestamps.append(now)
140 return True
141 return False
143 @property
144 def current_count(self) -> int:
145 return len(self._timestamps)
147 @property
148 def remaining(self) -> int:
149 return max(0, self.max_requests - len(self._timestamps))
152class ConcurrentLimiter:
153 """Limit the number of in-flight concurrent operations."""
155 def __init__(self, max_concurrent: int):
156 if max_concurrent <= 0:
157 raise ValueError("max_concurrent must be > 0")
158 self._semaphore = asyncio.Semaphore(max_concurrent)
160 async def acquire(self) -> bool:
161 """Acquire a slot. Returns False if cancelled."""
162 try:
163 await self._semaphore.acquire()
164 return True
165 except asyncio.CancelledError:
166 return False
168 def release(self) -> None:
169 """Release a slot."""
170 self._semaphore.release()
172 @property
173 def available(self) -> int:
174 return self._semaphore._value # pyright: ignore[reportPrivateUsage]
177class CompositeLimiter:
178 """Chain multiple limiters — all must pass for a request to proceed."""
180 def __init__(self, limiters: list):
181 self.limiters = limiters
183 async def acquire(self) -> bool:
184 """Try to acquire from all limiters simultaneously."""
185 results = await asyncio.gather(
186 *[limiter.acquire() for limiter in self.limiters],
187 return_exceptions=True,
188 )
189 for r in results:
190 if r is False or isinstance(r, Exception):
191 return False
192 return all(results)
195# ============================================================================
196# Rate limit decorator / context manager
197# ============================================================================
199class RateLimitError(Exception):
200 """Raised when rate limit is exceeded."""
201 pass
204class RateLimiter:
205 """Unified rate limiter wrapping any strategy."""
207 def __init__(
208 self,
209 name: str = "default",
210 strategy=None,
211 ):
212 self.name = name
213 self.strategy = strategy
215 async def __aenter__(self):
216 ok = await self.strategy.acquire() if self.strategy else True
217 if not ok:
218 raise RateLimitError(f"Rate limit exceeded: {self.name}")
219 return self
221 async def __aexit__(self, *args):
222 if isinstance(self.strategy, ConcurrentLimiter):
223 self.strategy.release()
225 @classmethod
226 def token_bucket(cls, name: str, rate: float, capacity: int) -> "RateLimiter":
227 return cls(name=name, strategy=TokenBucket(rate=rate, capacity=capacity))
229 @classmethod
230 def sliding_window(
231 cls, name: str, max_requests: int, window_seconds: float
232 ) -> "RateLimiter":
233 return cls(
234 name=name,
235 strategy=SlidingWindow(
236 max_requests=max_requests, window_seconds=window_seconds
237 ),
238 )
240 @classmethod
241 def concurrent(cls, name: str, max_concurrent: int) -> "RateLimiter":
242 return cls(name=name, strategy=ConcurrentLimiter(max_concurrent=max_concurrent))
245# ============================================================================
246# Endpoint-level registry
247# ============================================================================
249@dataclass
250class EndpointRateLimit:
251 """Per-endpoint rate limit configuration."""
253 endpoint: str
254 requests_per_second: Optional[float] = None
255 requests_per_minute: Optional[int] = None
256 concurrent: Optional[int] = None
257 burst: int = 1
260class RateLimitRegistry:
261 """Manage per-endpoint rate limiters."""
263 def __init__(self):
264 self._limiters: Dict[str, RateLimiter] = {}
265 self._lock = asyncio.Lock()
267 async def configure(self, spec: EndpointRateLimit) -> RateLimiter:
268 async with self._lock:
269 limiters = []
271 if spec.requests_per_second is not None:
272 limiters.append(
273 TokenBucket(
274 rate=spec.requests_per_second,
275 capacity=max(spec.burst, 1),
276 )
277 )
279 if spec.requests_per_minute is not None:
280 limiters.append(
281 SlidingWindow(
282 max_requests=spec.requests_per_minute,
283 window_seconds=60.0,
284 )
285 )
287 if spec.concurrent is not None:
288 limiters.append(ConcurrentLimiter(max_concurrent=spec.concurrent))
290 strategy = (
291 CompositeLimiter(limiters) if len(limiters) > 1
292 else limiters[0] if limiters
293 else None
294 )
296 limiter = RateLimiter(name=spec.endpoint, strategy=strategy)
297 self._limiters[spec.endpoint] = limiter
298 return limiter
300 async def get(self, endpoint: str) -> Optional[RateLimiter]:
301 return self._limiters.get(endpoint)
303 async def acquire(self, endpoint: str) -> bool:
304 limiter = self._limiters.get(endpoint)
305 if limiter is None:
306 return True
307 return await limiter.strategy.acquire() if limiter.strategy else True