"""In-process sliding-window rate limiter. IMPORTANT — deployment note: Rate limit counters are stored in memory and are lost on process restart. The bucket key is the client address from grimoire/proxy.py, read by the proxy hop count (Settings → Config). A count that is too low keys every caller on the proxy's address, so they all share one bucket. """ import asyncio import time from collections import defaultdict # Said beside every "too many attempts" reply, because the one case a person # can't diagnose on their own is everybody being blocked at once: the proxy # count is too low, so every visitor reads as the proxy and shares one bucket. SHARED_ADDRESS_HINT = ( "If everyone is being blocked, an admin should check the reverse proxy count " "in Settings → Config: every visitor may be reading as the proxy's address." ) _buckets: dict[str, list[float]] = defaultdict(list) _lock = asyncio.Lock() async def is_rate_limited(key: str, max_requests: int, window_seconds: int) -> bool: """Returns True if request should be blocked (limit exceeded).""" async with _lock: now = time.monotonic() cutoff = now - window_seconds _buckets[key] = [t for t in _buckets[key] if t > cutoff] if len(_buckets[key]) >= max_requests: return True _buckets[key].append(now) return False