Fail-fast sur la configuration. Une limite de debit illisible (10/hours, 10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la variable, au lieu de desactiver silencieusement la protection : seules les valeurs 0, off, none, disabled et vide desactivent volontairement. Meme traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS et chaque entree de TRUSTED_PROXY_CIDRS. X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS (ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est refuse au demarrage, faute de quoi la configuration semblerait active sans l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme IPv6 ne donnent plus des compteurs distincts. MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus, pour que deux creations simultanees ne franchissent pas ensemble la derniere place. Documente comme valable pour un service applicatif unique. Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en production, une option backup --keep pour la retention, et documente Nginx, Traefik en override, cron et systemd. Corrige un bug de rendu du build local : mon edition avait reecrit docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
4.9 KiB
Python
148 lines
4.9 KiB
Python
"""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 : <nombre>/<fenetre>, 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()
|