"""Limitation de debit en memoire, par client et par famille d'endpoint. Volontairement sans dependance externe ni Redis : OpenMind n'a qu'un seul service applicatif, et l'objectif de cette limitation est d'empecher qu'une instance publique soit triviale a saturer, pas de construire un quota distribue. Consequence assumee et documentee : les compteurs vivent dans le processus. Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn auraient chacun les leurs. C'est acceptable pour l'usage vise. """ from __future__ import annotations import threading import time from collections import deque from dataclasses import dataclass _WINDOW_SECONDS = { "second": 1, "sec": 1, "s": 1, "minute": 60, "min": 60, "m": 60, "hour": 3600, "h": 3600, "day": 86400, "d": 86400, } # Garde-fou memoire : au-dela, les entrees les plus anciennes sont oubliees. MAX_TRACKED_CLIENTS = 20000 @dataclass(frozen=True) class RateRule: """Nombre d'appels autorises sur une fenetre glissante.""" limit: int window_seconds: int def describe(self) -> str: return f"{self.limit} requete(s) par {self.window_seconds} seconde(s)" def parse_rate(spec: str) -> RateRule | None: """Lit une regle "10/hour". Retourne None si la limite est desactivee. Une valeur vide, "0", ou une syntaxe non reconnue desactive la limite : une configuration erronee ne doit jamais rendre l'instance inutilisable. """ text = (spec or "").strip().lower() if not text or text in {"0", "none", "off", "disabled"}: return None number, _, unit = text.partition("/") try: limit = int(number.strip()) except ValueError: return None if limit <= 0: return None window = _WINDOW_SECONDS.get(unit.strip() or "minute") if window is None: return None return RateRule(limit=limit, window_seconds=window) class RateLimiter: """Fenetre glissante par cle, protegee par un verrou.""" def __init__(self) -> None: self._hits: dict[str, deque[float]] = {} self._lock = threading.Lock() def check(self, key: str, rule: RateRule | None, *, now: float | None = None) -> int | None: """Enregistre un appel. Retourne `None` si l'appel est autorise, sinon le nombre de secondes a attendre avant le prochain essai (valeur de l'en-tete Retry-After). """ if rule is None: return None moment = time.monotonic() if now is None else now floor = moment - rule.window_seconds with self._lock: hits = self._hits.get(key) if hits is None: if len(self._hits) >= MAX_TRACKED_CLIENTS: self._forget_oldest_locked(floor) hits = deque() self._hits[key] = hits while hits and hits[0] <= floor: hits.popleft() if len(hits) >= rule.limit: wait = rule.window_seconds - (moment - hits[0]) return max(1, int(wait) + 1) hits.append(moment) return None def _forget_oldest_locked(self, floor: float) -> None: """Nettoie les cles dont toutes les traces sont hors fenetre.""" stale = [key for key, hits in self._hits.items() if not hits or hits[-1] <= floor] for key in stale: del self._hits[key] if not stale: # Aucune cle expiree : on repart de zero plutot que de grossir sans fin. self._hits.clear() def reset(self) -> None: """Remet tous les compteurs a zero. Utilise par les tests.""" with self._lock: self._hits.clear() limiter = RateLimiter()