feat(matrix-bridge-dagi): add rate limiting (H1) and metrics (H3)
H1 — InMemoryRateLimiter (sliding window, no Redis):
- Per-room: RATE_LIMIT_ROOM_RPM (default 20/min)
- Per-sender: RATE_LIMIT_SENDER_RPM (default 10/min)
- Room checked before sender — sender quota not charged on room block
- Blocked messages: audit matrix.rate_limited + on_rate_limited callback
- reset() for ops/test, stats() exposed in /health
H3 — Extended Prometheus metrics:
- matrix_bridge_rate_limited_total{room_id,agent_id,limit_type}
- matrix_bridge_send_duration_seconds histogram (invoke was already there)
- matrix_bridge_invoke_duration_seconds buckets tuned for LLM latency
- matrix_bridge_rate_limiter_active_rooms/senders gauges
- on_invoke_latency + on_send_latency callbacks wired in ingress loop
16 new tests: rate limiter unit (13) + ingress integration (3)
Total: 65 passed
Made-with: Cursor
This commit is contained in:
111
services/matrix-bridge-dagi/app/rate_limit.py
Normal file
111
services/matrix-bridge-dagi/app/rate_limit.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user