Coverage for src/lexigram/web/websocket/rate_limiter.py: 43%
35 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""WebSocket message rate limiter.
3Implements a per-connection token-bucket rate limiter for WebSocket messages.
4"""
6from __future__ import annotations
8import asyncio
9from dataclasses import dataclass
11from lexigram.primitives import clock as ambient_clock
14@dataclass
15class _BucketState:
16 """Token-bucket state for a single connection."""
18 tokens: float
19 last_refill: float = 0.0
22class WebSocketRateLimiter:
23 """Per-connection token-bucket rate limiter for WebSocket messages.
25 Each connection gets its own token bucket that refills at ``max_messages_per_second``
26 tokens per second. An incoming message consumes one token. If the bucket is empty
27 the message is rejected and ``check()`` returns ``False``.
29 State for connections that have been cleaned up via ``remove()`` is discarded
30 automatically.
32 Attributes:
33 max_messages_per_second: Maximum sustained message rate per connection.
35 Example:
36 ```python
37 limiter = WebSocketRateLimiter(max_messages_per_second=10.0)
38 connection_id_manager = get_connection_id_manager()
40 async def on_message(self, websocket, message):
41 conn_id = connection_id_manager.get_or_create(websocket)
42 if not await limiter.check(conn_id):
43 await websocket.send_json({"type": "error", "error": "rate_limit_exceeded"})
44 return
45 ...
46 ```
47 """
49 def __init__(
50 self,
51 max_messages_per_second: float = 10.0,
52 ) -> None:
53 """Initialise the rate limiter.
55 Args:
56 max_messages_per_second: Maximum number of messages per second per connection.
57 Must be a positive number.
58 """
59 if max_messages_per_second <= 0:
60 raise ValueError("max_messages_per_second must be positive")
61 self._max_rate = max_messages_per_second
62 self._connections: dict[str, _BucketState] = {}
63 self._lock = asyncio.Lock()
65 async def check(self, connection_id: str) -> bool:
66 """Check whether a message from ``connection_id`` is within the rate limit.
68 Refills the token bucket proportionally to elapsed time, then attempts
69 to consume one token. Returns ``True`` if the message is allowed,
70 ``False`` if the rate limit is exceeded.
72 Args:
73 connection_id: A unique identifier for the connection (UUID string).
75 Returns:
76 True if the message is allowed; False if rate-limited.
77 """
78 now = ambient_clock.monotonic()
80 async with self._lock:
81 if connection_id not in self._connections:
82 # New connection: start with a full bucket
83 self._connections[connection_id] = _BucketState(
84 tokens=self._max_rate,
85 last_refill=now,
86 )
88 bucket = self._connections[connection_id]
90 # Refill tokens based on elapsed time
91 elapsed = now - bucket.last_refill
92 bucket.tokens = min(
93 self._max_rate,
94 bucket.tokens + elapsed * self._max_rate,
95 )
96 bucket.last_refill = now
98 if bucket.tokens < 1.0:
99 return False
101 bucket.tokens -= 1.0
102 return True
104 async def remove(self, connection_id: str) -> None:
105 """Remove tracking state for a connection.
107 Call this when a WebSocket connection closes to free memory.
109 Args:
110 connection_id: The same identifier passed to ``check()``.
111 """
112 async with self._lock:
113 self._connections.pop(connection_id, None)
115 @property
116 def active_connection_count(self) -> int:
117 """Number of connections currently being tracked."""
118 return len(self._connections)
121__all__ = ["WebSocketRateLimiter"]