sliding_window.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. # -*- coding: utf-8 -*-
  2. """
  3. Sliding-Window-Algorithmus für Rate-Limiting.
  4. Implementiert das Sliding-Window-Verfahren für präzise
  5. zeitbasierte Durchsatzbegrenzung.
  6. """
  7. from __future__ import annotations
  8. import threading
  9. import time
  10. from collections import deque
  11. from dataclasses import dataclass, field
  12. from datetime import datetime
  13. from typing import Any
  14. @dataclass
  15. class SlidingWindowStats:
  16. """
  17. Statistiken für ein Sliding-Window.
  18. Bietet Einblick in die Nutzung des Zeitfensters.
  19. """
  20. requests_allowed: int = 0
  21. """Anzahl erlaubter Requests."""
  22. requests_denied: int = 0
  23. """Anzahl abgelehnter Requests."""
  24. last_request: datetime | None = None
  25. """Zeitpunkt des letzten Requests."""
  26. peak_requests_in_window: int = 0
  27. """Maximale Requests in einem Fenster."""
  28. @property
  29. def total_requests(self) -> int:
  30. """Gesamtzahl der Requests."""
  31. return self.requests_allowed + self.requests_denied
  32. @property
  33. def allow_rate(self) -> float:
  34. """Erlaubnisrate in Prozent."""
  35. if self.total_requests == 0:
  36. return 100.0
  37. return (self.requests_allowed / self.total_requests) * 100
  38. def record_allow(self, current_count: int) -> None:
  39. """Zeichnet einen erlaubten Request auf."""
  40. self.requests_allowed += 1
  41. self.last_request = datetime.now()
  42. self.peak_requests_in_window = max(
  43. self.peak_requests_in_window, current_count
  44. )
  45. def record_deny(self) -> None:
  46. """Zeichnet einen abgelehnten Request auf."""
  47. self.requests_denied += 1
  48. self.last_request = datetime.now()
  49. def reset(self) -> None:
  50. """Setzt alle Statistiken zurück."""
  51. self.requests_allowed = 0
  52. self.requests_denied = 0
  53. self.last_request = None
  54. self.peak_requests_in_window = 0
  55. def to_dict(self) -> dict[str, Any]:
  56. """Konvertiert die Statistiken in ein Dictionary."""
  57. return {
  58. "requests_allowed": self.requests_allowed,
  59. "requests_denied": self.requests_denied,
  60. "total_requests": self.total_requests,
  61. "allow_rate": self.allow_rate,
  62. "peak_requests_in_window": self.peak_requests_in_window,
  63. "last_request": (
  64. self.last_request.isoformat()
  65. if self.last_request else None
  66. ),
  67. }
  68. class SlidingWindow:
  69. """
  70. Sliding-Window-Implementierung für Rate-Limiting.
  71. Zählt Requests innerhalb eines gleitenden Zeitfensters.
  72. Im Gegensatz zum Token-Bucket bietet dies eine striktere
  73. Kontrolle ohne Burst-Möglichkeit.
  74. Attributes:
  75. window_size: Größe des Zeitfensters in Sekunden.
  76. max_requests: Maximale Requests im Fenster.
  77. Example:
  78. window = SlidingWindow(window_size=60.0, max_requests=100)
  79. if window.is_allowed():
  80. # Request im Fenster erlaubt
  81. process_request()
  82. else:
  83. # Rate limit erreicht
  84. raise TooManyRequests()
  85. """
  86. def __init__(
  87. self,
  88. window_size: float,
  89. max_requests: int,
  90. ) -> None:
  91. """
  92. Initialisiert das Sliding-Window.
  93. Args:
  94. window_size: Fenstergröße in Sekunden.
  95. max_requests: Maximale Requests pro Fenster.
  96. """
  97. self._window_size = window_size
  98. self._max_requests = max_requests
  99. self._timestamps: deque[float] = deque()
  100. self._lock = threading.Lock()
  101. self._stats = SlidingWindowStats()
  102. @property
  103. def window_size(self) -> float:
  104. """Gibt die Fenstergröße zurück."""
  105. return self._window_size
  106. @property
  107. def max_requests(self) -> int:
  108. """Gibt das Maximum zurück."""
  109. return self._max_requests
  110. @property
  111. def stats(self) -> SlidingWindowStats:
  112. """Gibt die Statistiken zurück."""
  113. return self._stats
  114. def _cleanup(self, now: float) -> None:
  115. """Entfernt abgelaufene Einträge."""
  116. cutoff = now - self._window_size
  117. while self._timestamps and self._timestamps[0] < cutoff:
  118. self._timestamps.popleft()
  119. @property
  120. def current_count(self) -> int:
  121. """Gibt die aktuelle Anzahl im Fenster zurück."""
  122. with self._lock:
  123. self._cleanup(time.monotonic())
  124. return len(self._timestamps)
  125. def is_allowed(self) -> bool:
  126. """
  127. Prüft, ob ein Request erlaubt ist und zeichnet ihn auf.
  128. Returns:
  129. True wenn erlaubt.
  130. """
  131. now = time.monotonic()
  132. with self._lock:
  133. self._cleanup(now)
  134. if len(self._timestamps) < self._max_requests:
  135. self._timestamps.append(now)
  136. self._stats.record_allow(len(self._timestamps))
  137. return True
  138. self._stats.record_deny()
  139. return False
  140. def try_acquire(self, count: int = 1) -> tuple[bool, float]:
  141. """
  142. Versucht, mehrere Slots zu reservieren.
  143. Args:
  144. count: Anzahl zu reservierender Slots.
  145. Returns:
  146. Tuple (erfolgreich, Wartezeit in Sekunden).
  147. """
  148. now = time.monotonic()
  149. with self._lock:
  150. self._cleanup(now)
  151. available = self._max_requests - len(self._timestamps)
  152. if available >= count:
  153. for _ in range(count):
  154. self._timestamps.append(now)
  155. self._stats.record_allow(len(self._timestamps))
  156. return True, 0.0
  157. # Berechne Wartezeit bis genug Slots frei werden
  158. if self._timestamps:
  159. needed = count - available
  160. # Wann werden die nächsten 'needed' Slots frei?
  161. if len(self._timestamps) >= needed:
  162. oldest_needed = list(self._timestamps)[needed - 1]
  163. wait_time = (oldest_needed + self._window_size) - now
  164. wait_time = max(0, wait_time)
  165. else:
  166. wait_time = self._window_size
  167. else:
  168. wait_time = 0
  169. self._stats.record_deny()
  170. return False, wait_time
  171. def wait_and_proceed(
  172. self,
  173. max_wait: float | None = None,
  174. ) -> bool:
  175. """
  176. Wartet und reserviert dann einen Slot.
  177. Args:
  178. max_wait: Maximale Wartezeit in Sekunden.
  179. Returns:
  180. True wenn erfolgreich.
  181. """
  182. start = time.monotonic()
  183. while True:
  184. success, wait_time = self.try_acquire()
  185. if success:
  186. return True
  187. if max_wait is not None:
  188. elapsed = time.monotonic() - start
  189. remaining = max_wait - elapsed
  190. if remaining <= 0:
  191. return False
  192. wait_time = min(wait_time, remaining)
  193. if wait_time > 0:
  194. time.sleep(wait_time)
  195. def remaining(self) -> int:
  196. """
  197. Gibt die Anzahl verbleibender Slots im Fenster zurück.
  198. Returns:
  199. Anzahl verfügbarer Slots.
  200. """
  201. with self._lock:
  202. self._cleanup(time.monotonic())
  203. return max(0, self._max_requests - len(self._timestamps))
  204. def time_until_available(self) -> float:
  205. """
  206. Gibt die Zeit bis zum nächsten freien Slot zurück.
  207. Returns:
  208. Wartezeit in Sekunden (0 wenn sofort verfügbar).
  209. """
  210. now = time.monotonic()
  211. with self._lock:
  212. self._cleanup(now)
  213. if len(self._timestamps) < self._max_requests:
  214. return 0.0
  215. if self._timestamps:
  216. oldest = self._timestamps[0]
  217. return max(0, (oldest + self._window_size) - now)
  218. return 0.0
  219. def reset(self) -> None:
  220. """Leert das Fenster."""
  221. with self._lock:
  222. self._timestamps.clear()
  223. def __repr__(self) -> str:
  224. """String-Repräsentation."""
  225. return (
  226. f"SlidingWindow(window_size={self._window_size}, "
  227. f"max_requests={self._max_requests}, "
  228. f"current={self.current_count})"
  229. )
  230. class SlidingWindowLimiter:
  231. """
  232. Rate-Limiter basierend auf Sliding-Windows.
  233. Verwaltet mehrere Fenster für verschiedene Schlüssel.
  234. """
  235. def __init__(
  236. self,
  237. window_size: float,
  238. max_requests: int,
  239. key_ttl: float = 3600.0,
  240. ) -> None:
  241. """
  242. Initialisiert den Limiter.
  243. Args:
  244. window_size: Fenstergröße in Sekunden.
  245. max_requests: Maximum pro Fenster pro Schlüssel.
  246. key_ttl: Zeit bis ungenutzte Fenster gelöscht werden.
  247. """
  248. self._window_size = window_size
  249. self._max_requests = max_requests
  250. self._key_ttl = key_ttl
  251. self._windows: dict[str, tuple[SlidingWindow, float]] = {}
  252. self._lock = threading.Lock()
  253. def _get_window(self, key: str) -> SlidingWindow:
  254. """Holt oder erstellt ein Fenster für einen Schlüssel."""
  255. now = time.monotonic()
  256. with self._lock:
  257. if key in self._windows:
  258. window, _ = self._windows[key]
  259. self._windows[key] = (window, now)
  260. return window
  261. window = SlidingWindow(
  262. window_size=self._window_size,
  263. max_requests=self._max_requests,
  264. )
  265. self._windows[key] = (window, now)
  266. return window
  267. def is_allowed(self, key: str) -> bool:
  268. """
  269. Prüft, ob ein Request erlaubt ist.
  270. Args:
  271. key: Identifikator für den Rate-Limit-Kontext.
  272. Returns:
  273. True wenn erlaubt.
  274. """
  275. window = self._get_window(key)
  276. return window.is_allowed()
  277. def get_remaining(self, key: str) -> int:
  278. """
  279. Gibt die verbleibenden Requests für einen Schlüssel zurück.
  280. Args:
  281. key: Identifikator.
  282. Returns:
  283. Anzahl verbleibender Requests.
  284. """
  285. window = self._get_window(key)
  286. return window.remaining()
  287. def get_retry_after(self, key: str) -> float:
  288. """
  289. Gibt die Wartezeit bis zum nächsten freien Slot zurück.
  290. Args:
  291. key: Identifikator.
  292. Returns:
  293. Wartezeit in Sekunden.
  294. """
  295. window = self._get_window(key)
  296. return window.time_until_available()
  297. def wait_and_proceed(
  298. self,
  299. key: str,
  300. max_wait: float | None = None,
  301. ) -> bool:
  302. """
  303. Wartet und reserviert dann einen Slot.
  304. Args:
  305. key: Identifikator.
  306. max_wait: Maximale Wartezeit.
  307. Returns:
  308. True wenn erfolgreich.
  309. """
  310. window = self._get_window(key)
  311. return window.wait_and_proceed(max_wait)
  312. def cleanup(self) -> int:
  313. """
  314. Entfernt abgelaufene Fenster.
  315. Returns:
  316. Anzahl der entfernten Fenster.
  317. """
  318. now = time.monotonic()
  319. expired_keys = []
  320. with self._lock:
  321. for key, (_, last_access) in self._windows.items():
  322. if now - last_access > self._key_ttl:
  323. expired_keys.append(key)
  324. for key in expired_keys:
  325. del self._windows[key]
  326. return len(expired_keys)
  327. def reset(self, key: str | None = None) -> None:
  328. """
  329. Setzt ein oder alle Fenster zurück.
  330. Args:
  331. key: Optionaler spezifischer Schlüssel.
  332. """
  333. with self._lock:
  334. if key:
  335. if key in self._windows:
  336. window, last_access = self._windows[key]
  337. window.reset()
  338. else:
  339. for window, _ in self._windows.values():
  340. window.reset()
  341. def get_stats(self, key: str) -> SlidingWindowStats | None:
  342. """
  343. Gibt Statistiken für einen Schlüssel zurück.
  344. Args:
  345. key: Identifikator.
  346. Returns:
  347. Statistiken oder None.
  348. """
  349. with self._lock:
  350. if key in self._windows:
  351. window, _ = self._windows[key]
  352. return window.stats
  353. return None
  354. @property
  355. def window_count(self) -> int:
  356. """Gibt die Anzahl aktiver Fenster zurück."""
  357. with self._lock:
  358. return len(self._windows)
  359. class SlidingWindowLog(SlidingWindow):
  360. """
  361. Erweiterte Sliding-Window-Implementierung mit Logging.
  362. Speichert zusätzlich Metadaten zu jedem Request für
  363. Debugging und Analyse.
  364. """
  365. def __init__(
  366. self,
  367. window_size: float,
  368. max_requests: int,
  369. log_size: int = 1000,
  370. ) -> None:
  371. """
  372. Initialisiert das Sliding-Window mit Logging.
  373. Args:
  374. window_size: Fenstergröße in Sekunden.
  375. max_requests: Maximale Requests pro Fenster.
  376. log_size: Maximale Anzahl gespeicherter Log-Einträge.
  377. """
  378. super().__init__(window_size, max_requests)
  379. self._log: deque[dict[str, Any]] = deque(maxlen=log_size)
  380. def is_allowed(self, metadata: dict[str, Any] | None = None) -> bool:
  381. """
  382. Prüft, ob ein Request erlaubt ist und loggt ihn.
  383. Args:
  384. metadata: Optionale Metadaten zum Request.
  385. Returns:
  386. True wenn erlaubt.
  387. """
  388. result = super().is_allowed()
  389. entry = {
  390. "timestamp": datetime.now().isoformat(),
  391. "allowed": result,
  392. "current_count": self.current_count,
  393. "remaining": self.remaining(),
  394. }
  395. if metadata:
  396. entry["metadata"] = metadata
  397. self._log.append(entry)
  398. return result
  399. def get_log(
  400. self,
  401. limit: int | None = None,
  402. allowed_only: bool = False,
  403. denied_only: bool = False,
  404. ) -> list[dict[str, Any]]:
  405. """
  406. Gibt das Log zurück.
  407. Args:
  408. limit: Optionale Begrenzung der Einträge.
  409. allowed_only: Nur erlaubte Requests.
  410. denied_only: Nur abgelehnte Requests.
  411. Returns:
  412. Liste von Log-Einträgen.
  413. """
  414. entries = list(self._log)
  415. if allowed_only:
  416. entries = [e for e in entries if e["allowed"]]
  417. elif denied_only:
  418. entries = [e for e in entries if not e["allowed"]]
  419. if limit:
  420. entries = entries[-limit:]
  421. return entries
  422. def clear_log(self) -> None:
  423. """Löscht das Log."""
  424. self._log.clear()