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>
This commit is contained in:
2026-09-27 13:04:21 +02:00
co-authored by Claude Opus 5.5
parent 87dfcd55b7
commit 763c5678f1
3 changed files with 58 additions and 7 deletions
+18 -5
View File
@@ -15,9 +15,15 @@ requests are distributed round robin.
A random legal action is played instead (so a game never gets stuck) when no 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 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 an illegal action, too many requests are already waiting for it
(``max_inflight``), or the circuit breaker is open: after ``breaker_failures`` (``max_inflight``), or the circuit breaker is open.
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. 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 from __future__ import annotations
@@ -36,6 +42,9 @@ from wizard_game import Action, Observation, action_from_dict
logger = logging.getLogger(__name__) 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: class AIConnection:
def __init__(self, websocket: WebSocket, name: str) -> None: def __init__(self, websocket: WebSocket, name: str) -> None:
@@ -94,7 +103,7 @@ class AIBridge:
if loop.time() < self._breaker_open_until: if loop.time() < self._breaker_open_until:
return self._fallback(observation, "AI paused after repeated failures", logging.DEBUG) return self._fallback(observation, "AI paused after repeated failures", logging.DEBUG)
if len(connection.pending) >= self._max_inflight: if len(connection.pending) >= self._max_inflight:
return self._failure(observation, f"{len(connection.pending)} AI requests already waiting") return self._fallback(observation, f"{len(connection.pending)} AI requests already waiting")
request_id = uuid.uuid4().hex request_id = uuid.uuid4().hex
future: asyncio.Future[dict[str, Any]] = loop.create_future() future: asyncio.Future[dict[str, Any]] = loop.create_future()
@@ -113,7 +122,11 @@ class AIBridge:
connection.pending.pop(request_id, None) connection.pending.pop(request_id, None)
if reply.get("type") != "action": if reply.get("type") != "action":
return self._failure(observation, f"AI error: {reply.get('message')}") 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: try:
action = action_from_dict(reply.get("action", {})) action = action_from_dict(reply.get("action", {}))
except ValueError as exc: except ValueError as exc:
+3 -1
View File
@@ -69,7 +69,9 @@ class Settings:
# --- AI service protection ---------------------------------------------- # --- AI service protection ----------------------------------------------
# Unanswered decide requests per AI connection before falling back. # Unanswered decide requests per AI connection before falling back.
ai_max_inflight: int = _int("AI_MAX_INFLIGHT", 32) # Each running game has at most one AI deciding at a time, so keep this at
# least MAX_ACTIVE_GAMES; 64 matches the AI service's AI_MAX_QUEUE.
ai_max_inflight: int = _int("AI_MAX_INFLIGHT", 64)
# Consecutive AI failures that pause AI requests for ai_breaker_cooldown seconds. # Consecutive AI failures that pause AI requests for ai_breaker_cooldown seconds.
ai_breaker_failures: int = _int("AI_BREAKER_FAILURES", 5) ai_breaker_failures: int = _int("AI_BREAKER_FAILURES", 5)
ai_breaker_cooldown: float = _float("AI_BREAKER_COOLDOWN", 30.0) ai_breaker_cooldown: float = _float("AI_BREAKER_COOLDOWN", 30.0)
+37 -1
View File
@@ -200,6 +200,40 @@ def test_ai_circuit_breaker():
asyncio.run(scenario()) asyncio.run(scenario())
class OverloadedAIWebSocket:
"""Answers every request immediately with the given error message."""
def __init__(self, bridge_connection_ref, message):
self.ref, self.message, self.sent = bridge_connection_ref, message, []
async def send_json(self, request):
self.sent.append(request)
future = self.ref[0].pending[request["request_id"]]
future.set_result({"type": "error", "request_id": request["request_id"], "message": self.message})
def test_load_shedding_does_not_trip_the_breaker():
async def scenario():
bridge = AIBridge(timeout=1, rng=random.Random(0), breaker_failures=2)
ref = []
ws = OverloadedAIWebSocket(ref, "overloaded")
connection = AIConnection(ws, "fake")
ref.append(connection)
bridge._connections.append(connection)
obs = observation()
for _ in range(5):
assert await bridge.decide(obs) in obs.legal_actions
assert len(ws.sent) == 5 # still asked every time
assert bridge.stats()["paused"] is False
ws.message = "model crashed" # a real failure counts
for _ in range(3):
await bridge.decide(obs)
assert len(ws.sent) == 7 and bridge.stats()["paused"] is True
asyncio.run(scenario())
def test_ai_inflight_limit(): def test_ai_inflight_limit():
async def scenario(): async def scenario():
bridge = AIBridge(timeout=1, rng=random.Random(0), max_inflight=1) bridge = AIBridge(timeout=1, rng=random.Random(0), max_inflight=1)
@@ -208,7 +242,9 @@ def test_ai_inflight_limit():
connection.pending["busy"] = asyncio.get_running_loop().create_future() connection.pending["busy"] = asyncio.get_running_loop().create_future()
bridge._connections.append(connection) bridge._connections.append(connection)
obs = observation() obs = observation()
assert await bridge.decide(obs) in obs.legal_actions for _ in range(10):
assert await bridge.decide(obs) in obs.legal_actions
assert ws.sent == [] # not even sent: too many requests already waiting assert ws.sent == [] # not even sent: too many requests already waiting
assert bridge.stats()["paused"] is False # load shedding is not a failure
asyncio.run(scenario()) asyncio.run(scenario())