Add resource limits, abuse protection and overload handling

Running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.

- Docker: CPU/memory/pids caps per container (overridable in .env);
  uvicorn message size, queue and concurrency limits
- Server abuse limits: connections total/per IP (close 1013), message
  rate per connection (token bucket, close 1008 on floods), lobby
  creation rate per IP
- Load monitor: event loop lag, CPU of the own cgroup quota and
  throttling, with hysteresis (normal/busy/overloaded, severe lag
  overloads immediately)
- Admission control: create_lobby and start_game refused while
  overloaded or MAX_ACTIVE_GAMES are running; server_status message
  broadcast on changes; /health reports load but stays ok
- AI bridge: timeout sent with requests, in-flight cap, circuit breaker
- AI service: bounded workers and queue ("overloaded"), drops expired
  requests, degrades to the heuristic agent when busy or too slow
- Website: busy notices, Create/Start disabled when not accepting
  games, slow retry when the server is full
- Tests for limiters, load levels, admission control and AI service
  limits; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 12:51:46 +02:00
co-authored by Claude Opus 5.5
parent 905574e8f1
commit 87dfcd55b7
33 changed files with 1591 additions and 128 deletions
+52 -11
View File
@@ -4,14 +4,20 @@ The AI service (``wizard_ai``) connects to ``/internal/ai`` via websocket.
Whenever an AI seat has to act, the server sends it the seat's observation
and waits for an action:
server -> ai: {"type": "decide", "request_id": "...", "observation": {...}}
ai -> server: {"type": "action", "request_id": "...", "action": {...}}
server -> ai: {"type": "decide", "request_id": "...", "timeout": 5.0, "observation": {...}}
ai -> server: {"type": "action", "request_id": "...", "action": {...}, "agent": "heuristic"}
ai -> server: {"type": "error", "request_id": "...", "message": "..."}
Several AI service instances may connect; requests are distributed round
robin. If no instance is connected, it does not answer in time, or it answers
with an illegal action, a random legal action is played instead so a game
never gets stuck.
``timeout`` tells the AI service how long the server waits, so it can drop
requests that are already too old. Several AI service instances may connect;
requests are distributed round robin.
A random legal action is played instead (so a game never gets stuck) when no
instance is connected, it does not answer in time, it answers with an error or
an illegal action, too many requests are already waiting for it
(``max_inflight``), or the circuit breaker is open: after ``breaker_failures``
failures in a row the AI is not asked for ``breaker_cooldown`` seconds, so a
struggling AI service is not buried under even more requests.
"""
from __future__ import annotations
@@ -55,6 +61,9 @@ class AIBridge:
timeout: float,
rng: random.Random | None = None,
on_change: Callable[[], None] | None = None,
max_inflight: int = 32,
breaker_failures: int = 5,
breaker_cooldown: float = 30.0,
) -> None:
self.timeout = timeout
# Called whenever an AI service connects or disconnects.
@@ -62,6 +71,11 @@ class AIBridge:
self._connections: list[AIConnection] = []
self._round_robin = itertools.count()
self._rng = rng or random.Random()
self._max_inflight = max_inflight
self._breaker_failures = breaker_failures
self._breaker_cooldown = breaker_cooldown
self._consecutive_failures = 0
self._breaker_open_until = 0.0
@property
def available(self) -> bool:
@@ -76,32 +90,49 @@ class AIBridge:
connection = self._pick_connection()
if connection is None:
return self._fallback(observation, "no AI service connected", logging.DEBUG)
loop = asyncio.get_running_loop()
if loop.time() < self._breaker_open_until:
return self._fallback(observation, "AI paused after repeated failures", logging.DEBUG)
if len(connection.pending) >= self._max_inflight:
return self._failure(observation, f"{len(connection.pending)} AI requests already waiting")
request_id = uuid.uuid4().hex
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
connection.pending[request_id] = future
try:
await connection.send({
"type": "decide",
"request_id": request_id,
"timeout": self.timeout,
"observation": observation.to_dict(),
})
reply = await asyncio.wait_for(future, self.timeout)
except (TimeoutError, ConnectionError, RuntimeError, WebSocketDisconnect) as exc:
return self._fallback(observation, f"AI request failed: {exc!r}")
return self._failure(observation, f"AI request failed: {exc!r}")
finally:
connection.pending.pop(request_id, None)
if reply.get("type") != "action":
return self._fallback(observation, f"AI error: {reply.get('message')}")
return self._failure(observation, f"AI error: {reply.get('message')}")
try:
action = action_from_dict(reply.get("action", {}))
except ValueError as exc:
return self._fallback(observation, f"malformed AI action: {exc}")
return self._failure(observation, f"malformed AI action: {exc}")
if action not in legal:
return self._fallback(observation, f"illegal AI action {action}")
return self._failure(observation, f"illegal AI action {action}")
self._consecutive_failures = 0
if reply.get("agent"):
logger.debug("AI decision by %s agent", reply["agent"])
return action
def stats(self) -> dict[str, Any]:
loop_time = asyncio.get_running_loop().time()
return {
"connections": len(self._connections),
"pending": sum(len(c.pending) for c in self._connections),
"paused": loop_time < self._breaker_open_until,
}
async def serve(self, websocket: WebSocket) -> None:
"""Handle one AI service connection until it disconnects."""
connection = AIConnection(websocket, name=websocket.client.host if websocket.client else "?")
@@ -136,6 +167,16 @@ class AIBridge:
return None
return self._connections[next(self._round_robin) % len(self._connections)]
def _failure(self, observation: Observation, reason: str) -> Action:
"""A failed AI request: count it for the circuit breaker, then fall back."""
self._consecutive_failures += 1
if self._consecutive_failures >= self._breaker_failures:
self._consecutive_failures = 0
self._breaker_open_until = asyncio.get_running_loop().time() + self._breaker_cooldown
logger.warning("AI failed %d times in a row, pausing AI requests for %.0fs",
self._breaker_failures, self._breaker_cooldown)
return self._fallback(observation, reason)
def _fallback(self, observation: Observation, reason: str, level: int = logging.WARNING) -> Action:
logger.log(level, "Playing random move for player %d: %s", observation.player, reason)
return self._rng.choice(observation.legal_actions)