Spaces:
Running
Running
File size: 4,006 Bytes
42ce1bc 5f80ef1 42ce1bc 5f80ef1 7786593 42ce1bc 5f80ef1 42ce1bc 5f80ef1 42ce1bc 5f80ef1 f95285c 42ce1bc 7786593 5f80ef1 42ce1bc 0b65b92 42ce1bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | """
lumen_limits.py. Скользящее окно rate limit и очередь кнопок уточнений.
Выделено из bot.py (P2 аудита). Хранилища в памяти процесса, без Telegram и LLM.
bot.py реэкспортирует имена, тесты через bot.X не менялись.
"""
from __future__ import annotations
import time
from typing import Any
# Числа через bot._env_number с отложенным импортом.
# Опечатка в env не роняет старт (аудит 26.09.2026).
def _env_number(name: str, default: float | int, *, cast: type = float, min_value: float | None = None) -> float | int:
try:
import bot
return bot._env_number(name, default, cast=cast, min_value=min_value)
except Exception:
return cast(default)
RATE_LIMIT_MAX_REQUESTS = _env_number("RATE_LIMIT_MAX_REQUESTS", 5, cast=int, min_value=1)
RATE_LIMIT_WINDOW_SEC = _env_number("RATE_LIMIT_WINDOW_SEC", 30, min_value=1)
MAX_RATE_LIMIT_KEYS = _env_number("MAX_RATE_LIMIT_KEYS", 20000, cast=int, min_value=100)
user_rate_limits: dict[int, list[float]] = {}
def _cleanup_rate_limit_dict() -> None:
"""Чистка раз в час. Пустые ключи копились навсегда и давали медленную утечку."""
now = time.time()
stale = [uid for uid, ts in user_rate_limits.items() if not ts or now - ts[-1] > 3600]
for uid in stale:
user_rate_limits.pop(uid, None)
def _check_and_register_rate_limit(user_id: int | None) -> bool:
"""Окно 5/30с. При отказе метка не пишется, иначе отказ продлевал бы сам себя."""
if not user_id:
return False
now = time.time()
if user_id not in user_rate_limits and len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
_cleanup_rate_limit_dict()
# Чистка убирает только протухших, при флуде свежими ID нужен жесткий снос (аудит).
# Сносим до 90 процентов потолка, чтобы не сканировать словарь на каждый новый ID.
if len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
ordered = sorted(
user_rate_limits,
key=lambda uid: user_rate_limits[uid][-1] if user_rate_limits[uid] else 0,
)
drop = len(ordered) - int(MAX_RATE_LIMIT_KEYS * 0.9) + 1
for uid in ordered[:drop]:
user_rate_limits.pop(uid, None)
timestamps = user_rate_limits.setdefault(user_id, [])
while timestamps and now - timestamps[0] > RATE_LIMIT_WINDOW_SEC:
timestamps.pop(0)
if len(timestamps) >= RATE_LIMIT_MAX_REQUESTS:
return True
timestamps.append(now)
return False
PICK_TTL_SEC = _env_number("PICK_TTL_SEC", 300, min_value=1)
MAX_PENDING_PICKS = _env_number("MAX_PENDING_PICKS", 500, cast=int, min_value=1)
# Только память. После рестарта кнопки протухают, это штатно.
_pending_picks: dict[str, dict[str, Any]] = {}
def _purge_expired_picks(now: float | None = None) -> None:
"""Чистка протухших при создании новой: отдельный цикл не заводили."""
now = time.monotonic() if now is None else now
for token in [t for t, rec in _pending_picks.items() if rec["expires"] <= now]:
del _pending_picks[token]
def _enforce_pending_picks_cap() -> None:
"""Потолок памяти на кнопки-уточнения: сверх лимита сносим самые
близкие к протуханию (AUD-G-001)."""
overflow = len(_pending_picks) - MAX_PENDING_PICKS
if overflow <= 0:
return
oldest = sorted(_pending_picks, key=lambda t: _pending_picks[t]["expires"])[:overflow]
for token in oldest:
del _pending_picks[token]
|