Files
Wizard/wizard_server/app/ai_bridge.py
T
marvinandClaude Opus 5.5 763c5678f1 Don't trip the AI circuit breaker on load shedding
With many simultaneous games, AI turns start in bursts and exceed the
in-flight cap. That was counted as an AI failure, so after 5 bursts the
breaker paused the AI for 30 s and every AI seat played random moves.

- Only real failures (timeouts, lost connection, malformed/illegal
  answers, unexpected errors) count towards the breaker; the in-flight
  cap and "overloaded"/"expired" answers from the AI service do not
- AI_MAX_INFLIGHT default 32 -> 64 (>= MAX_ACTIVE_GAMES, matches the AI
  service's AI_MAX_QUEUE)
- Test that load shedding keeps the breaker closed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:04:21 +02:00

196 lines
8.3 KiB
Python

"""Bridge between the game server and the AI service.
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": "...", "timeout": 5.0, "observation": {...}}
ai -> server: {"type": "action", "request_id": "...", "action": {...}, "agent": "heuristic"}
ai -> server: {"type": "error", "request_id": "...", "message": "..."}
``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.
The circuit breaker protects against a *broken or hanging* AI service: after
``breaker_failures`` real failures in a row (timeouts, lost connection,
malformed or illegal answers, unexpected errors) the AI is not asked for
``breaker_cooldown`` seconds, so games don't wait ``timeout`` seconds for every
AI move. Deliberate load shedding is *not* a failure and does not trip it: the
in-flight cap, and the AI answering "overloaded" or "expired" (it is healthy
and answered immediately).
"""
from __future__ import annotations
import asyncio
import itertools
import logging
import random
import uuid
from collections.abc import Callable
from typing import Any
from starlette.websockets import WebSocket, WebSocketDisconnect
from wizard_game import Action, Observation, action_from_dict
logger = logging.getLogger(__name__)
# Errors with which a healthy AI service sheds load (see wizard_ai/service/client.py).
LOAD_SHEDDING_ERRORS = ("overloaded", "expired")
class AIConnection:
def __init__(self, websocket: WebSocket, name: str) -> None:
self.websocket = websocket
self.name = name
self.pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
self._send_lock = asyncio.Lock()
async def send(self, message: dict[str, Any]) -> None:
async with self._send_lock:
await self.websocket.send_json(message)
def fail_pending(self) -> None:
for future in self.pending.values():
if not future.done():
future.set_exception(ConnectionError("AI service disconnected"))
self.pending.clear()
class AIBridge:
def __init__(
self,
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.
self._on_change = on_change
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:
return bool(self._connections)
async def decide(self, observation: Observation) -> Action:
"""Ask the AI service for an action; falls back to a random legal one."""
legal = observation.legal_actions
if not legal:
raise ValueError("Observation has no legal actions")
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._fallback(observation, f"{len(connection.pending)} AI requests already waiting")
request_id = uuid.uuid4().hex
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._failure(observation, f"AI request failed: {exc!r}")
finally:
connection.pending.pop(request_id, None)
if reply.get("type") != "action":
message = reply.get("message")
if message in LOAD_SHEDDING_ERRORS:
self._consecutive_failures = 0 # the service is alive and protecting itself
return self._fallback(observation, f"AI service {message}")
return self._failure(observation, f"AI error: {message}")
try:
action = action_from_dict(reply.get("action", {}))
except ValueError as exc:
return self._failure(observation, f"malformed AI action: {exc}")
if action not in legal:
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 "?")
self._connections.append(connection)
logger.info("AI service connected from %s (%d connected)", connection.name, len(self._connections))
self._notify_change()
try:
while True:
message = await websocket.receive_json()
if not isinstance(message, dict):
continue
if message.get("type") == "hello":
logger.info("AI service %s: %s", connection.name, message.get("agent"))
continue
future = connection.pending.get(str(message.get("request_id")))
if future and not future.done():
future.set_result(message)
except (WebSocketDisconnect, RuntimeError, ValueError):
pass
finally:
self._connections.remove(connection)
connection.fail_pending()
logger.info("AI service %s disconnected (%d connected)", connection.name, len(self._connections))
self._notify_change()
def _notify_change(self) -> None:
if self._on_change is not None:
self._on_change()
def _pick_connection(self) -> AIConnection | None:
if not self._connections:
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)