decorators.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. # -*- coding: utf-8 -*-
  2. """
  3. Rate-Limit-Decorators für Funktionen.
  4. Bietet einfache Decorators für Durchsatzbegrenzung von
  5. Funktionen und Methoden.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import functools
  10. import hashlib
  11. from dataclasses import dataclass
  12. from typing import Any, Callable, ParamSpec, TypeVar
  13. from trixy_core.utils.ratelimit.token_bucket import TokenBucket, TokenBucketLimiter
  14. from trixy_core.utils.ratelimit.sliding_window import SlidingWindow, SlidingWindowLimiter
  15. P = ParamSpec("P")
  16. R = TypeVar("R")
  17. class RateLimitExceeded(Exception):
  18. """
  19. Exception wenn Rate-Limit überschritten wurde.
  20. Enthält Informationen zur Wartezeit bis zur nächsten
  21. erlaubten Ausführung.
  22. """
  23. def __init__(
  24. self,
  25. message: str = "Rate limit exceeded",
  26. retry_after: float | None = None,
  27. limit_key: str | None = None,
  28. ) -> None:
  29. """
  30. Initialisiert die Exception.
  31. Args:
  32. message: Fehlermeldung.
  33. retry_after: Zeit in Sekunden bis zur nächsten Erlaubnis.
  34. limit_key: Identifikator des Rate-Limits.
  35. """
  36. super().__init__(message)
  37. self.retry_after = retry_after
  38. self.limit_key = limit_key
  39. def __str__(self) -> str:
  40. """String-Repräsentation."""
  41. msg = super().__str__()
  42. if self.retry_after:
  43. msg += f" (retry after {self.retry_after:.2f}s)"
  44. return msg
  45. @dataclass
  46. class RateLimitConfig:
  47. """
  48. Konfiguration für Rate-Limiting.
  49. Definiert die Parameter für den Rate-Limiter.
  50. """
  51. # Token-Bucket-Parameter
  52. capacity: float = 10.0
  53. """Maximale Bucket-Kapazität (Token-Bucket)."""
  54. refill_rate: float = 1.0
  55. """Tokens pro Sekunde (Token-Bucket)."""
  56. # Sliding-Window-Parameter
  57. window_size: float = 60.0
  58. """Fenstergröße in Sekunden (Sliding-Window)."""
  59. max_requests: int = 60
  60. """Maximale Requests pro Fenster (Sliding-Window)."""
  61. # Allgemeine Parameter
  62. algorithm: str = "token_bucket"
  63. """Algorithmus: 'token_bucket' oder 'sliding_window'."""
  64. key_func: Callable[..., str] | None = None
  65. """Funktion zur Schlüsselgenerierung für Per-Key-Limiting."""
  66. block: bool = False
  67. """Ob gewartet werden soll statt Exception zu werfen."""
  68. max_wait: float | None = None
  69. """Maximale Wartezeit wenn block=True."""
  70. on_limited: Callable[[str], None] | None = None
  71. """Callback wenn Limit erreicht."""
  72. # Globale Limiter-Instanzen
  73. _token_bucket_limiters: dict[str, TokenBucketLimiter] = {}
  74. _sliding_window_limiters: dict[str, SlidingWindowLimiter] = {}
  75. def _get_key(
  76. func: Callable,
  77. args: tuple,
  78. kwargs: dict,
  79. key_func: Callable[..., str] | None,
  80. ) -> str:
  81. """Generiert einen Schlüssel für die Rate-Limit-Instanz."""
  82. if key_func:
  83. return key_func(*args, **kwargs)
  84. # Default: Funktionsname als Schlüssel
  85. return func.__qualname__
  86. def rate_limit(
  87. capacity: float = 10.0,
  88. refill_rate: float = 1.0,
  89. window_size: float = 60.0,
  90. max_requests: int = 60,
  91. algorithm: str = "token_bucket",
  92. key_func: Callable[..., str] | None = None,
  93. block: bool = False,
  94. max_wait: float | None = None,
  95. on_limited: Callable[[str], None] | None = None,
  96. config: RateLimitConfig | None = None,
  97. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  98. """
  99. Decorator für Rate-Limiting von Funktionen.
  100. Args:
  101. capacity: Token-Bucket-Kapazität.
  102. refill_rate: Token-Bucket Refill-Rate.
  103. window_size: Sliding-Window-Größe.
  104. max_requests: Sliding-Window Maximum.
  105. algorithm: 'token_bucket' oder 'sliding_window'.
  106. key_func: Optionale Funktion für Per-Key-Limiting.
  107. block: Ob gewartet werden soll.
  108. max_wait: Maximale Wartezeit.
  109. on_limited: Callback bei Limit-Erreichen.
  110. config: Alternative Konfiguration als Objekt.
  111. Returns:
  112. Decorator-Funktion.
  113. Example:
  114. @rate_limit(capacity=10, refill_rate=2)
  115. def api_call():
  116. return requests.get(url)
  117. @rate_limit(
  118. algorithm="sliding_window",
  119. window_size=60,
  120. max_requests=100,
  121. key_func=lambda user_id: str(user_id)
  122. )
  123. def user_action(user_id):
  124. pass
  125. @rate_limit(block=True, max_wait=5.0)
  126. def blocking_call():
  127. # Wartet bis zu 5 Sekunden
  128. pass
  129. """
  130. if config:
  131. cfg = config
  132. else:
  133. cfg = RateLimitConfig(
  134. capacity=capacity,
  135. refill_rate=refill_rate,
  136. window_size=window_size,
  137. max_requests=max_requests,
  138. algorithm=algorithm,
  139. key_func=key_func,
  140. block=block,
  141. max_wait=max_wait,
  142. on_limited=on_limited,
  143. )
  144. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  145. limiter_name = func.__qualname__
  146. # Limiter erstellen
  147. if cfg.algorithm == "token_bucket":
  148. if limiter_name not in _token_bucket_limiters:
  149. _token_bucket_limiters[limiter_name] = TokenBucketLimiter(
  150. capacity=cfg.capacity,
  151. refill_rate=cfg.refill_rate,
  152. )
  153. limiter = _token_bucket_limiters[limiter_name]
  154. else:
  155. if limiter_name not in _sliding_window_limiters:
  156. _sliding_window_limiters[limiter_name] = SlidingWindowLimiter(
  157. window_size=cfg.window_size,
  158. max_requests=cfg.max_requests,
  159. )
  160. limiter = _sliding_window_limiters[limiter_name]
  161. @functools.wraps(func)
  162. def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  163. key = _get_key(func, args, kwargs, cfg.key_func)
  164. if cfg.block:
  165. # Warten und dann ausführen
  166. if isinstance(limiter, TokenBucketLimiter):
  167. success = limiter.wait_and_proceed(key, max_wait=cfg.max_wait)
  168. else:
  169. success = limiter.wait_and_proceed(key, max_wait=cfg.max_wait)
  170. if not success:
  171. if cfg.on_limited:
  172. cfg.on_limited(key)
  173. raise RateLimitExceeded(
  174. "Rate limit exceeded after waiting",
  175. limit_key=key,
  176. )
  177. else:
  178. # Sofortige Prüfung
  179. if not limiter.is_allowed(key):
  180. if cfg.on_limited:
  181. cfg.on_limited(key)
  182. # Retry-After berechnen
  183. if isinstance(limiter, TokenBucketLimiter):
  184. retry_after = limiter.get_wait_time(key)
  185. else:
  186. retry_after = limiter.get_retry_after(key)
  187. raise RateLimitExceeded(
  188. "Rate limit exceeded",
  189. retry_after=retry_after,
  190. limit_key=key,
  191. )
  192. return func(*args, **kwargs)
  193. # Zugriff auf Limiter und Stats
  194. wrapper.limiter = limiter # type: ignore
  195. wrapper.reset = lambda k=None: limiter.reset(k) # type: ignore
  196. return wrapper
  197. return decorator
  198. def async_rate_limit(
  199. capacity: float = 10.0,
  200. refill_rate: float = 1.0,
  201. window_size: float = 60.0,
  202. max_requests: int = 60,
  203. algorithm: str = "token_bucket",
  204. key_func: Callable[..., str] | None = None,
  205. block: bool = False,
  206. max_wait: float | None = None,
  207. on_limited: Callable[[str], None] | None = None,
  208. config: RateLimitConfig | None = None,
  209. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  210. """
  211. Async-Decorator für Rate-Limiting.
  212. Wie @rate_limit, aber für async Funktionen.
  213. Args:
  214. Siehe rate_limit.
  215. Returns:
  216. Decorator-Funktion.
  217. Example:
  218. @async_rate_limit(capacity=10, refill_rate=2)
  219. async def api_call():
  220. return await aiohttp_session.get(url)
  221. """
  222. if config:
  223. cfg = config
  224. else:
  225. cfg = RateLimitConfig(
  226. capacity=capacity,
  227. refill_rate=refill_rate,
  228. window_size=window_size,
  229. max_requests=max_requests,
  230. algorithm=algorithm,
  231. key_func=key_func,
  232. block=block,
  233. max_wait=max_wait,
  234. on_limited=on_limited,
  235. )
  236. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  237. limiter_name = func.__qualname__
  238. if cfg.algorithm == "token_bucket":
  239. if limiter_name not in _token_bucket_limiters:
  240. _token_bucket_limiters[limiter_name] = TokenBucketLimiter(
  241. capacity=cfg.capacity,
  242. refill_rate=cfg.refill_rate,
  243. )
  244. limiter = _token_bucket_limiters[limiter_name]
  245. else:
  246. if limiter_name not in _sliding_window_limiters:
  247. _sliding_window_limiters[limiter_name] = SlidingWindowLimiter(
  248. window_size=cfg.window_size,
  249. max_requests=cfg.max_requests,
  250. )
  251. limiter = _sliding_window_limiters[limiter_name]
  252. @functools.wraps(func)
  253. async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  254. key = _get_key(func, args, kwargs, cfg.key_func)
  255. if cfg.block:
  256. # Async warten
  257. start_wait = 0.0
  258. while True:
  259. if limiter.is_allowed(key):
  260. break
  261. if isinstance(limiter, TokenBucketLimiter):
  262. wait_time = limiter.get_wait_time(key)
  263. else:
  264. wait_time = limiter.get_retry_after(key)
  265. if cfg.max_wait is not None:
  266. if start_wait + wait_time > cfg.max_wait:
  267. if cfg.on_limited:
  268. cfg.on_limited(key)
  269. raise RateLimitExceeded(
  270. "Rate limit exceeded after waiting",
  271. limit_key=key,
  272. )
  273. await asyncio.sleep(min(wait_time, 0.1))
  274. start_wait += min(wait_time, 0.1)
  275. else:
  276. if not limiter.is_allowed(key):
  277. if cfg.on_limited:
  278. cfg.on_limited(key)
  279. if isinstance(limiter, TokenBucketLimiter):
  280. retry_after = limiter.get_wait_time(key)
  281. else:
  282. retry_after = limiter.get_retry_after(key)
  283. raise RateLimitExceeded(
  284. "Rate limit exceeded",
  285. retry_after=retry_after,
  286. limit_key=key,
  287. )
  288. return await func(*args, **kwargs)
  289. wrapper.limiter = limiter # type: ignore
  290. wrapper.reset = lambda k=None: limiter.reset(k) # type: ignore
  291. return wrapper
  292. return decorator
  293. def throttle(
  294. calls: int,
  295. period: float,
  296. key_func: Callable[..., str] | None = None,
  297. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  298. """
  299. Vereinfachter Decorator für Throttling.
  300. Erlaubt eine bestimmte Anzahl von Aufrufen pro Zeitperiode.
  301. Args:
  302. calls: Maximale Anzahl Aufrufe.
  303. period: Zeitperiode in Sekunden.
  304. key_func: Optionale Funktion für Per-Key-Throttling.
  305. Returns:
  306. Decorator-Funktion.
  307. Example:
  308. @throttle(calls=10, period=60) # 10 Aufrufe pro Minute
  309. def api_call():
  310. pass
  311. """
  312. return rate_limit(
  313. algorithm="sliding_window",
  314. window_size=period,
  315. max_requests=calls,
  316. key_func=key_func,
  317. )
  318. def debounce(
  319. wait: float,
  320. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  321. """
  322. Decorator für Debouncing.
  323. Verhindert mehrfache Aufrufe innerhalb einer kurzen Zeitspanne.
  324. Nur der letzte Aufruf innerhalb des Wartezeitraums wird ausgeführt.
  325. Args:
  326. wait: Wartezeit in Sekunden.
  327. Returns:
  328. Decorator-Funktion.
  329. Example:
  330. @debounce(0.5)
  331. def on_input_change(value):
  332. # Wird nur ausgeführt wenn 0.5s keine weiteren Aufrufe
  333. process(value)
  334. """
  335. import time
  336. import threading
  337. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  338. last_call = [0.0]
  339. timer: list[threading.Timer | None] = [None]
  340. lock = threading.Lock()
  341. last_args: list[tuple] = [()]
  342. last_kwargs: list[dict] = [{}]
  343. @functools.wraps(func)
  344. def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
  345. with lock:
  346. last_args[0] = args
  347. last_kwargs[0] = kwargs
  348. if timer[0]:
  349. timer[0].cancel()
  350. def execute():
  351. func(*last_args[0], **last_kwargs[0])
  352. timer[0] = threading.Timer(wait, execute)
  353. timer[0].start()
  354. return wrapper # type: ignore
  355. return decorator