"""Ограничитель частоты запросов со скользящим окном (в памяти одного процесса).""" from __future__ import annotations import math import threading import time from collections import OrderedDict, deque from typing import Callable class RateLimiter: def __init__(self, clock: Callable[[], float] = time.monotonic, max_keys: int = 50_000, purge_interval: int = 60) -> None: self._clock = clock self._max_keys = max_keys self._purge_interval = purge_interval self._last_purge = float("-inf") self._lock = threading.Lock() self._hits: OrderedDict[str, tuple[deque[float], int]] = OrderedDict() def check(self, key: str, limit: int, window: int) -> tuple[bool, int]: """Учитывает попытку. Возвращает (разрешено, через сколько секунд повторить).""" now = self._clock() with self._lock: entry = self._hits.get(key) hits = entry[0] if entry else deque() while hits and hits[0] <= now - window: hits.popleft() if len(hits) >= limit: self._hits[key] = (hits, window) return False, max(1, math.ceil(hits[0] + window - now)) hits.append(now) self._hits[key] = (hits, window) self._hits.move_to_end(key) self._evict(now) return True, 0 def is_blocked(self, key: str, limit: int, window: int) -> tuple[bool, int]: """Проверяет лимит, не засчитывая попытку (вместе с record() считает только неудачи).""" now = self._clock() with self._lock: entry = self._hits.get(key) hits = entry[0] if entry else deque() while hits and hits[0] <= now - window: hits.popleft() if len(hits) >= limit: return True, max(1, math.ceil(hits[0] + window - now)) return False, 0 def record(self, key: str, window: int) -> None: now = self._clock() with self._lock: entry = self._hits.get(key) hits = entry[0] if entry else deque() hits.append(now) self._hits[key] = (hits, window) self._hits.move_to_end(key) self._evict(now) def forgive(self, key: str) -> None: """Возвращает последнюю учтённую попытку (успешный вход не должен приближать блокировку).""" with self._lock: entry = self._hits.get(key) if entry and entry[0]: entry[0].pop() def _evict(self, now: float) -> None: if len(self._hits) <= self._max_keys: return # Полный проход по ключам дорог, поэтому не чаще раза в purge_interval; иначе просто вытесняем самые старые. if now - self._last_purge >= self._purge_interval: self._last_purge = now for key in [k for k, (hits, window) in self._hits.items() if not hits or hits[-1] <= now - window]: del self._hits[key] while len(self._hits) > self._max_keys: self._hits.popitem(last=False) def size(self) -> int: with self._lock: return len(self._hits)