""" In-memory rate limiter for matrix-bridge-dagi (H1) Sliding window algorithm — no external dependencies (no Redis needed for M1). Thread-safe within a single asyncio event loop. Two independent limiters per message: - room limiter: max N messages per room per minute - sender limiter: max N messages per sender per minute Usage: rl = InMemoryRateLimiter(room_rpm=20, sender_rpm=10) allowed, limit_type = rl.check(room_id="!abc:server", sender="@user:server") if not allowed: # reject, audit matrix.rate_limited """ import time from collections import defaultdict, deque from typing import Deque, Dict, Optional, Tuple class InMemoryRateLimiter: """ Sliding-window rate limiter, independent per room and per sender. Windows are pruned lazily on each check — no background task needed. Each bucket stores timestamps (float) of accepted events within the window. """ _WINDOW_S: float = 60.0 # 1-minute window def __init__(self, room_rpm: int = 20, sender_rpm: int = 10) -> None: if room_rpm < 1 or sender_rpm < 1: raise ValueError("RPM limits must be >= 1") self._room_rpm = room_rpm self._sender_rpm = sender_rpm # buckets: key → deque of accepted timestamps self._room_buckets: Dict[str, Deque[float]] = defaultdict(deque) self._sender_buckets: Dict[str, Deque[float]] = defaultdict(deque) @property def room_rpm(self) -> int: return self._room_rpm @property def sender_rpm(self) -> int: return self._sender_rpm def check(self, room_id: str, sender: str) -> Tuple[bool, Optional[str]]: """ Check if message is allowed. Returns: (True, None) — allowed (False, "room") — room limit exceeded (False, "sender") — sender limit exceeded Room is checked first; if room limit hit, sender bucket is NOT updated to avoid penalising user's quota for messages already blocked. """ now = time.monotonic() cutoff = now - self._WINDOW_S # Check room room_bucket = self._room_buckets[room_id] self._prune(room_bucket, cutoff) if len(room_bucket) >= self._room_rpm: return False, "room" # Check sender sender_bucket = self._sender_buckets[sender] self._prune(sender_bucket, cutoff) if len(sender_bucket) >= self._sender_rpm: return False, "sender" # Both allowed — record room_bucket.append(now) sender_bucket.append(now) return True, None def reset(self, room_id: Optional[str] = None, sender: Optional[str] = None) -> None: """Clear buckets — useful for tests or manual ops reset.""" if room_id is not None: self._room_buckets.pop(room_id, None) if sender is not None: self._sender_buckets.pop(sender, None) def stats(self) -> dict: """Read-only snapshot of current bucket sizes (for /health or ops).""" now = time.monotonic() cutoff = now - self._WINDOW_S return { "room_rpm_limit": self._room_rpm, "sender_rpm_limit": self._sender_rpm, "active_rooms": sum( 1 for b in self._room_buckets.values() if any(t > cutoff for t in b) ), "active_senders": sum( 1 for b in self._sender_buckets.values() if any(t > cutoff for t in b) ), } @staticmethod def _prune(bucket: Deque[float], cutoff: float) -> None: """Remove timestamps older than the window.""" while bucket and bucket[0] <= cutoff: bucket.popleft()