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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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
+ ]