Lumen / lumen_limits.py
SilverElixir
Comment cleanup: short Russian why-comments, no AI boilerplate
5f80ef1
Raw History Blame Contribute Delete
4.01 kB
"""
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]