"""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 # Seules ces valeurs desactivent volontairement une limite. Toute autre valeur # non analysable est une faute de frappe, pas une intention. DISABLED_VALUES = frozenset({"", "0", "off", "none", "disabled"}) class RateLimitSyntaxError(ValueError): """Regle de debit illisible. Remonte comme erreur de configuration.""" @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. Desactiver une limite se fait explicitement : valeur vide, `0`, `off`, `none` ou `disabled`. Toute autre valeur illisible leve une erreur plutot que d'etre silencieusement traitee comme une desactivation : une faute de frappe comme `10/hours` laisserait sinon l'instance sans protection, sans que personne ne s'en apercoive. """ text = (spec or "").strip().lower() if text in DISABLED_VALUES: return None number, separator, unit = text.partition("/") try: limit = int(number.strip()) except ValueError: raise RateLimitSyntaxError( f"'{spec}' n'est pas une limite de debit valide : le nombre est illisible. " "Attendu : /, par exemple 10/hour." ) from None if limit <= 0: raise RateLimitSyntaxError( f"'{spec}' n'est pas une limite de debit valide : le nombre doit etre positif. " "Pour desactiver la limite, utilisez 0, off, ou une valeur vide." ) # "10" vaut 10/minute ; "10/" est une fenetre oubliee, pas un raccourci. unit_name = unit.strip() if separator else "minute" window = _WINDOW_SECONDS.get(unit_name) if window is None: allowed = ", ".join(sorted({"second", "minute", "hour", "day"})) raise RateLimitSyntaxError( f"'{spec}' n'est pas une limite de debit valide : fenetre '{unit_name}' inconnue. " f"Fenetres acceptees : {allowed}." ) 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()