kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Rate limiter using the Token Bucket algorithm.
|
|
2
|
+
|
|
3
|
+
Provides configurable rate limiting for protecting against request floods
|
|
4
|
+
and respecting external API rate limits.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
As a decorator:
|
|
8
|
+
|
|
9
|
+
>>> @rate_limiter(rate=10, per=1.0) # 10 requests per second
|
|
10
|
+
... def call_api(): # doctest: +SKIP
|
|
11
|
+
... return requests.get("http://api.example.com")
|
|
12
|
+
|
|
13
|
+
Direct usage:
|
|
14
|
+
|
|
15
|
+
>>> limiter = RateLimiter(rate=5, per=1.0)
|
|
16
|
+
>>> limiter.acquire() # Blocks until token available
|
|
17
|
+
True
|
|
18
|
+
>>> limiter.try_acquire() # Non-blocking, returns immediately
|
|
19
|
+
True
|
|
20
|
+
|
|
21
|
+
As context manager:
|
|
22
|
+
|
|
23
|
+
>>> with RateLimiter(rate=100, per=60.0): # doctest: +SKIP
|
|
24
|
+
... call_api()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import functools
|
|
31
|
+
import inspect
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import TYPE_CHECKING, TypeVar, overload
|
|
36
|
+
|
|
37
|
+
from typing_extensions import ParamSpec, Self
|
|
38
|
+
|
|
39
|
+
from kstlib.resilience.exceptions import RateLimitExceededError
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
|
|
44
|
+
P = ParamSpec("P")
|
|
45
|
+
R = TypeVar("R")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RateLimiterStats:
|
|
50
|
+
"""Statistics for rate limiter monitoring.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
total_acquired: Total number of tokens successfully acquired.
|
|
54
|
+
total_rejected: Total number of acquire attempts that were rejected.
|
|
55
|
+
total_waited: Total time spent waiting for tokens (seconds).
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
>>> stats = RateLimiterStats()
|
|
59
|
+
>>> stats.record_acquired()
|
|
60
|
+
>>> stats.record_rejected()
|
|
61
|
+
>>> stats.record_wait(0.5)
|
|
62
|
+
>>> (stats.total_acquired, stats.total_rejected)
|
|
63
|
+
(1, 1)
|
|
64
|
+
>>> stats.total_waited
|
|
65
|
+
0.5
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
total_acquired: int = 0
|
|
69
|
+
total_rejected: int = 0
|
|
70
|
+
total_waited: float = 0.0
|
|
71
|
+
|
|
72
|
+
def record_acquired(self) -> None:
|
|
73
|
+
"""Record a successful token acquisition."""
|
|
74
|
+
self.total_acquired += 1
|
|
75
|
+
|
|
76
|
+
def record_rejected(self) -> None:
|
|
77
|
+
"""Record a rejected acquisition attempt."""
|
|
78
|
+
self.total_rejected += 1
|
|
79
|
+
|
|
80
|
+
def record_wait(self, seconds: float) -> None:
|
|
81
|
+
"""Record time spent waiting for a token."""
|
|
82
|
+
self.total_waited += seconds
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RateLimiter:
|
|
86
|
+
"""Token bucket rate limiter for controlling request throughput.
|
|
87
|
+
|
|
88
|
+
Implements the token bucket algorithm where tokens are added at a fixed
|
|
89
|
+
rate and each request consumes one token. Allows bursts up to the
|
|
90
|
+
bucket capacity.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
rate: Maximum number of tokens (requests) allowed per period.
|
|
94
|
+
per: Time period in seconds (default 1.0 = per second).
|
|
95
|
+
burst: Initial tokens available. If None, starts full (burst = rate).
|
|
96
|
+
name: Optional name for logging and monitoring.
|
|
97
|
+
|
|
98
|
+
Examples:
|
|
99
|
+
Basic usage - 10 requests per second:
|
|
100
|
+
|
|
101
|
+
>>> limiter = RateLimiter(rate=10, per=1.0)
|
|
102
|
+
>>> int(limiter.tokens) # Starts full
|
|
103
|
+
10
|
|
104
|
+
|
|
105
|
+
With custom burst capacity:
|
|
106
|
+
|
|
107
|
+
>>> limiter = RateLimiter(rate=10, per=1.0, burst=5)
|
|
108
|
+
>>> int(limiter.tokens) # Starts with 5 tokens
|
|
109
|
+
5
|
|
110
|
+
|
|
111
|
+
Rate limiting API calls:
|
|
112
|
+
|
|
113
|
+
>>> limiter = RateLimiter(rate=100, per=60.0) # 100 per minute
|
|
114
|
+
>>> for _ in range(5):
|
|
115
|
+
... if limiter.try_acquire():
|
|
116
|
+
... pass # call_api()
|
|
117
|
+
>>> limiter.stats.total_acquired
|
|
118
|
+
5
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
rate: float,
|
|
124
|
+
per: float = 1.0,
|
|
125
|
+
*,
|
|
126
|
+
burst: float | None = None,
|
|
127
|
+
name: str | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Initialize rate limiter.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
rate: Maximum tokens (requests) per period.
|
|
133
|
+
per: Period duration in seconds.
|
|
134
|
+
burst: Initial token count. Defaults to rate (full bucket).
|
|
135
|
+
name: Optional name for identification.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValueError: If rate or per is not positive.
|
|
139
|
+
"""
|
|
140
|
+
if rate <= 0:
|
|
141
|
+
raise ValueError("rate must be positive")
|
|
142
|
+
if per <= 0:
|
|
143
|
+
raise ValueError("per must be positive")
|
|
144
|
+
|
|
145
|
+
self._rate = float(rate)
|
|
146
|
+
self._per = float(per)
|
|
147
|
+
self._tokens = float(burst) if burst is not None else self._rate
|
|
148
|
+
self._max_tokens = self._rate
|
|
149
|
+
self._refill_rate = self._rate / self._per # tokens per second
|
|
150
|
+
self._last_refill = time.monotonic()
|
|
151
|
+
self._lock = threading.Lock()
|
|
152
|
+
self._name = name
|
|
153
|
+
self._stats = RateLimiterStats()
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def rate(self) -> float:
|
|
157
|
+
"""Maximum tokens per period."""
|
|
158
|
+
return self._rate
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def per(self) -> float:
|
|
162
|
+
"""Period duration in seconds."""
|
|
163
|
+
return self._per
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def tokens(self) -> float:
|
|
167
|
+
"""Current available tokens (after refill)."""
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._refill()
|
|
170
|
+
return self._tokens
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def stats(self) -> RateLimiterStats:
|
|
174
|
+
"""Statistics for this rate limiter."""
|
|
175
|
+
return self._stats
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def name(self) -> str | None:
|
|
179
|
+
"""Name of this rate limiter."""
|
|
180
|
+
return self._name
|
|
181
|
+
|
|
182
|
+
def _refill(self) -> None:
|
|
183
|
+
"""Refill tokens based on elapsed time. Must hold lock."""
|
|
184
|
+
now = time.monotonic()
|
|
185
|
+
elapsed = now - self._last_refill
|
|
186
|
+
self._tokens = min(self._max_tokens, self._tokens + elapsed * self._refill_rate)
|
|
187
|
+
self._last_refill = now
|
|
188
|
+
|
|
189
|
+
def _time_until_token(self) -> float:
|
|
190
|
+
"""Calculate time until at least 1 token is available. Must hold lock."""
|
|
191
|
+
if self._tokens >= 1.0:
|
|
192
|
+
return 0.0
|
|
193
|
+
needed = 1.0 - self._tokens
|
|
194
|
+
return needed / self._refill_rate
|
|
195
|
+
|
|
196
|
+
def time_until_token(self) -> float:
|
|
197
|
+
"""Calculate time until at least 1 token will be available.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Seconds until a token is available. Returns 0.0 if token available now.
|
|
201
|
+
|
|
202
|
+
Examples:
|
|
203
|
+
>>> limiter = RateLimiter(rate=10, per=1.0)
|
|
204
|
+
>>> limiter.time_until_token() # Tokens available
|
|
205
|
+
0.0
|
|
206
|
+
"""
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._refill()
|
|
209
|
+
return self._time_until_token()
|
|
210
|
+
|
|
211
|
+
def acquire(self, *, blocking: bool = True, timeout: float | None = None) -> bool:
|
|
212
|
+
"""Acquire a token from the bucket.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
blocking: If True, wait until a token is available.
|
|
216
|
+
timeout: Maximum time to wait in seconds (None = wait forever).
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if token was acquired, False if non-blocking and no token.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
RateLimitExceededError: If timeout exceeded while waiting.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
>>> limiter = RateLimiter(rate=10, per=1.0)
|
|
226
|
+
>>> limiter.acquire() # Blocks if needed
|
|
227
|
+
True
|
|
228
|
+
>>> limiter.acquire(blocking=False) # Returns immediately
|
|
229
|
+
True
|
|
230
|
+
"""
|
|
231
|
+
start_time = time.monotonic()
|
|
232
|
+
deadline = start_time + timeout if timeout is not None else None
|
|
233
|
+
|
|
234
|
+
while True:
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._refill()
|
|
237
|
+
|
|
238
|
+
if self._tokens >= 1.0:
|
|
239
|
+
self._tokens -= 1.0
|
|
240
|
+
wait_time = time.monotonic() - start_time
|
|
241
|
+
if wait_time > 0.001: # Only record significant waits
|
|
242
|
+
self._stats.record_wait(wait_time)
|
|
243
|
+
self._stats.record_acquired()
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
if not blocking:
|
|
247
|
+
self._stats.record_rejected()
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
# Calculate wait time
|
|
251
|
+
wait_time = self._time_until_token()
|
|
252
|
+
|
|
253
|
+
# Check timeout
|
|
254
|
+
if deadline is not None:
|
|
255
|
+
remaining = deadline - time.monotonic()
|
|
256
|
+
if remaining <= 0:
|
|
257
|
+
self._stats.record_rejected()
|
|
258
|
+
raise RateLimitExceededError(
|
|
259
|
+
f"Rate limit timeout after {timeout}s",
|
|
260
|
+
retry_after=wait_time,
|
|
261
|
+
)
|
|
262
|
+
wait_time = min(wait_time, remaining)
|
|
263
|
+
|
|
264
|
+
# Wait outside the lock
|
|
265
|
+
time.sleep(min(wait_time, 0.1)) # Cap sleep to allow interruption
|
|
266
|
+
|
|
267
|
+
def try_acquire(self) -> bool:
|
|
268
|
+
"""Try to acquire a token without blocking.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
True if token was acquired, False otherwise.
|
|
272
|
+
|
|
273
|
+
Examples:
|
|
274
|
+
>>> limiter = RateLimiter(rate=2, per=1.0)
|
|
275
|
+
>>> limiter.try_acquire()
|
|
276
|
+
True
|
|
277
|
+
>>> limiter.try_acquire()
|
|
278
|
+
True
|
|
279
|
+
>>> limiter.try_acquire() # No tokens left
|
|
280
|
+
False
|
|
281
|
+
"""
|
|
282
|
+
return self.acquire(blocking=False)
|
|
283
|
+
|
|
284
|
+
async def acquire_async(self, *, timeout: float | None = None) -> bool:
|
|
285
|
+
"""Acquire a token asynchronously.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
timeout: Maximum time to wait in seconds.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
True when token is acquired.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
RateLimitExceededError: If timeout exceeded.
|
|
295
|
+
|
|
296
|
+
Examples:
|
|
297
|
+
>>> import asyncio
|
|
298
|
+
>>> limiter = RateLimiter(rate=10, per=1.0)
|
|
299
|
+
>>> asyncio.run(limiter.acquire_async())
|
|
300
|
+
True
|
|
301
|
+
"""
|
|
302
|
+
start_time = time.monotonic()
|
|
303
|
+
deadline = start_time + timeout if timeout is not None else None
|
|
304
|
+
|
|
305
|
+
while True:
|
|
306
|
+
with self._lock:
|
|
307
|
+
self._refill()
|
|
308
|
+
|
|
309
|
+
if self._tokens >= 1.0:
|
|
310
|
+
self._tokens -= 1.0
|
|
311
|
+
wait_time = time.monotonic() - start_time
|
|
312
|
+
if wait_time > 0.001:
|
|
313
|
+
self._stats.record_wait(wait_time)
|
|
314
|
+
self._stats.record_acquired()
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
wait_time = self._time_until_token()
|
|
318
|
+
|
|
319
|
+
if deadline is not None:
|
|
320
|
+
remaining = deadline - time.monotonic()
|
|
321
|
+
if remaining <= 0:
|
|
322
|
+
self._stats.record_rejected()
|
|
323
|
+
raise RateLimitExceededError(
|
|
324
|
+
f"Rate limit timeout after {timeout}s",
|
|
325
|
+
retry_after=wait_time,
|
|
326
|
+
)
|
|
327
|
+
wait_time = min(wait_time, remaining)
|
|
328
|
+
|
|
329
|
+
# Async sleep outside the lock
|
|
330
|
+
await asyncio.sleep(min(wait_time, 0.1))
|
|
331
|
+
|
|
332
|
+
def reset(self) -> None:
|
|
333
|
+
"""Reset the rate limiter to full capacity.
|
|
334
|
+
|
|
335
|
+
Examples:
|
|
336
|
+
>>> limiter = RateLimiter(rate=5, per=1.0)
|
|
337
|
+
>>> for _ in range(5):
|
|
338
|
+
... limiter.try_acquire()
|
|
339
|
+
True
|
|
340
|
+
True
|
|
341
|
+
True
|
|
342
|
+
True
|
|
343
|
+
True
|
|
344
|
+
>>> limiter.try_acquire()
|
|
345
|
+
False
|
|
346
|
+
>>> limiter.reset()
|
|
347
|
+
>>> limiter.try_acquire()
|
|
348
|
+
True
|
|
349
|
+
"""
|
|
350
|
+
with self._lock:
|
|
351
|
+
self._tokens = self._max_tokens
|
|
352
|
+
self._last_refill = time.monotonic()
|
|
353
|
+
|
|
354
|
+
def __enter__(self) -> Self:
|
|
355
|
+
"""Enter context manager, acquiring a token."""
|
|
356
|
+
self.acquire()
|
|
357
|
+
return self
|
|
358
|
+
|
|
359
|
+
def __exit__(
|
|
360
|
+
self,
|
|
361
|
+
exc_type: type[BaseException] | None,
|
|
362
|
+
exc_val: BaseException | None,
|
|
363
|
+
exc_tb: object,
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Exit context manager."""
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
async def __aenter__(self) -> Self:
|
|
369
|
+
"""Enter async context manager, acquiring a token."""
|
|
370
|
+
await self.acquire_async()
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
async def __aexit__(
|
|
374
|
+
self,
|
|
375
|
+
exc_type: type[BaseException] | None,
|
|
376
|
+
exc_val: BaseException | None,
|
|
377
|
+
exc_tb: object,
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Exit async context manager."""
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
def __repr__(self) -> str:
|
|
383
|
+
"""Return string representation."""
|
|
384
|
+
name_part = f", name={self._name!r}" if self._name else ""
|
|
385
|
+
return f"RateLimiter(rate={self._rate}, per={self._per}{name_part})"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Type overloads for the decorator
|
|
389
|
+
@overload
|
|
390
|
+
def rate_limiter(fn: Callable[P, R]) -> Callable[P, R]: ...
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@overload
|
|
394
|
+
def rate_limiter(
|
|
395
|
+
fn: None = None,
|
|
396
|
+
*,
|
|
397
|
+
rate: float = 10.0,
|
|
398
|
+
per: float = 1.0,
|
|
399
|
+
burst: float | None = None,
|
|
400
|
+
blocking: bool = True,
|
|
401
|
+
timeout: float | None = None,
|
|
402
|
+
name: str | None = None,
|
|
403
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def rate_limiter(
|
|
407
|
+
fn: Callable[P, R] | None = None,
|
|
408
|
+
*,
|
|
409
|
+
rate: float = 10.0,
|
|
410
|
+
per: float = 1.0,
|
|
411
|
+
burst: float | None = None,
|
|
412
|
+
blocking: bool = True,
|
|
413
|
+
timeout: float | None = None,
|
|
414
|
+
name: str | None = None,
|
|
415
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
416
|
+
"""Decorator to rate limit function calls.
|
|
417
|
+
|
|
418
|
+
Can be used with or without arguments:
|
|
419
|
+
|
|
420
|
+
- ``@rate_limiter`` - Uses defaults (10 requests/second)
|
|
421
|
+
- ``@rate_limiter(rate=5, per=1.0)`` - 5 requests per second
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
fn: Function to decorate (when used without parentheses).
|
|
425
|
+
rate: Maximum calls per period (default 10).
|
|
426
|
+
per: Period in seconds (default 1.0).
|
|
427
|
+
burst: Initial capacity (default = rate).
|
|
428
|
+
blocking: If True, wait for token. If False, raise on limit.
|
|
429
|
+
timeout: Maximum wait time in seconds.
|
|
430
|
+
name: Name for the rate limiter.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Decorated function that respects rate limits.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
RateLimitExceededError: If blocking=False and rate limit exceeded.
|
|
437
|
+
|
|
438
|
+
Examples:
|
|
439
|
+
Default rate limiting (10/sec):
|
|
440
|
+
|
|
441
|
+
>>> @rate_limiter
|
|
442
|
+
... def call_api(): # doctest: +SKIP
|
|
443
|
+
... pass
|
|
444
|
+
|
|
445
|
+
Custom rate:
|
|
446
|
+
|
|
447
|
+
>>> @rate_limiter(rate=100, per=60.0) # 100 per minute
|
|
448
|
+
... def call_api(): # doctest: +SKIP
|
|
449
|
+
... pass
|
|
450
|
+
|
|
451
|
+
Non-blocking mode:
|
|
452
|
+
|
|
453
|
+
>>> @rate_limiter(rate=5, blocking=False)
|
|
454
|
+
... def fast_api(): # doctest: +SKIP
|
|
455
|
+
... pass # Raises RateLimitExceededError if limit hit
|
|
456
|
+
"""
|
|
457
|
+
# Create the limiter instance (shared across all calls)
|
|
458
|
+
limiter = RateLimiter(rate=rate, per=per, burst=burst, name=name)
|
|
459
|
+
|
|
460
|
+
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
|
|
461
|
+
# Check if function is async
|
|
462
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
463
|
+
|
|
464
|
+
if is_async:
|
|
465
|
+
|
|
466
|
+
@functools.wraps(fn)
|
|
467
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
468
|
+
if blocking:
|
|
469
|
+
await limiter.acquire_async(timeout=timeout)
|
|
470
|
+
elif not limiter.try_acquire():
|
|
471
|
+
raise RateLimitExceededError(
|
|
472
|
+
f"Rate limit exceeded for {fn.__name__}",
|
|
473
|
+
retry_after=limiter.time_until_token(),
|
|
474
|
+
)
|
|
475
|
+
return await fn(*args, **kwargs) # type: ignore[no-any-return, misc]
|
|
476
|
+
|
|
477
|
+
# Attach limiter for inspection
|
|
478
|
+
async_wrapper._rate_limiter = limiter # type: ignore[attr-defined]
|
|
479
|
+
return async_wrapper # type: ignore[return-value]
|
|
480
|
+
|
|
481
|
+
@functools.wraps(fn)
|
|
482
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
483
|
+
if blocking:
|
|
484
|
+
limiter.acquire(timeout=timeout)
|
|
485
|
+
elif not limiter.try_acquire():
|
|
486
|
+
raise RateLimitExceededError(
|
|
487
|
+
f"Rate limit exceeded for {fn.__name__}",
|
|
488
|
+
retry_after=limiter.time_until_token(),
|
|
489
|
+
)
|
|
490
|
+
return fn(*args, **kwargs)
|
|
491
|
+
|
|
492
|
+
# Attach limiter for inspection
|
|
493
|
+
sync_wrapper._rate_limiter = limiter # type: ignore[attr-defined]
|
|
494
|
+
return sync_wrapper
|
|
495
|
+
|
|
496
|
+
# Handle @rate_limiter vs @rate_limiter()
|
|
497
|
+
if fn is not None:
|
|
498
|
+
return decorator(fn)
|
|
499
|
+
return decorator
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
__all__ = [
|
|
503
|
+
"RateLimiter",
|
|
504
|
+
"RateLimiterStats",
|
|
505
|
+
"rate_limiter",
|
|
506
|
+
]
|