Files
Wizard/wizard_server/tests/test_server.py
T
marvinandClaude Opus 5.5 87dfcd55b7 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>
2026-09-27 12:51:46 +02:00

170 lines
6.4 KiB
Python

import random
import threading
import pytest
from helpers import SECRET, connect, is_state, receive_until
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_requires_hello(client):
with client.websocket_connect("/ws") as ws:
ws.send_json({"type": "create_lobby", "name": "Ann"})
assert ws.receive_json()["type"] == "error"
def test_invalid_message(client):
ws, _ = connect(client)
ws.send_json({"type": "join_lobby", "code": "ABCDEF", "name": ""})
assert ws.receive_json()["type"] == "error"
def test_lobby_flow_and_full_game(client):
host, host_welcome = connect(client)
host.send_json({"type": "create_lobby", "name": "Ann"})
state = receive_until(host, is_state)
lobby = state["lobby"]
code = lobby["code"]
assert lobby["host_id"] == host_welcome["player_id"]
assert [p["name"] for p in lobby["players"]] == ["Ann"]
guest, _ = connect(client)
guest.send_json({"type": "join_lobby", "code": code.lower(), "name": "Ann"})
state = receive_until(guest, lambda m: is_state(m) and m["lobby"])
assert [p["name"] for p in state["lobby"]["players"]] == ["Ann", "Ann (2)"]
# Only the host may start / add AI players.
guest.send_json({"type": "add_ai"})
assert receive_until(guest, lambda m: m["type"] == "error")
host.send_json({"type": "start_game"})
assert "needs" in receive_until(host, lambda m: m["type"] == "error")["message"]
host.send_json({"type": "add_ai"})
host.send_json({"type": "set_options", "hidden_bids": True})
host.send_json({"type": "start_game"})
rng = random.Random(0)
finished = {}
def play(ws, key):
while True:
message = ws.receive_json()
if not is_state(message) or not message["game"]:
continue
obs = message["game"]["observation"]
if obs["phase"] == "finished":
finished[key] = obs
return
if obs["current_player"] == message["game"]["you"] and obs["legal_actions"]:
ws.send_json({"type": "action", "action": rng.choice(obs["legal_actions"])})
threads = [threading.Thread(target=play, args=(ws, key)) for ws, key in ((host, "host"), (guest, "guest"))]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=60)
assert set(finished) == {"host", "guest"}
result = finished["host"]
assert len(result["round_history"]) == 20
assert result["winners"]
host.send_json({"type": "return_to_lobby"})
state = receive_until(host, lambda m: is_state(m) and m["lobby"]["status"] == "waiting")
assert state["game"] is None
def test_reconnect_with_token(client):
ws, welcome = connect(client)
ws.send_json({"type": "create_lobby", "name": "Bob"})
code = receive_until(ws, is_state)["lobby"]["code"]
ws.close()
ws2 = client.sockets.enter_context(client.websocket_connect("/ws"))
ws2.send_json({"type": "hello", "token": welcome["token"]})
again = receive_until(ws2, lambda m: m["type"] == "welcome")
assert again["player_id"] == welcome["player_id"]
state = receive_until(ws2, is_state)
assert state["lobby"]["code"] == code
def test_leaving_mid_game_hands_seat_to_ai(client):
host, _ = connect(client)
host.send_json({"type": "create_lobby", "name": "Cy"})
code = receive_until(host, is_state)["lobby"]["code"]
guest, _ = connect(client)
guest.send_json({"type": "join_lobby", "code": code, "name": "Di"})
receive_until(guest, lambda m: is_state(m) and m["lobby"])
host.send_json({"type": "add_ai"})
host.send_json({"type": "start_game"})
receive_until(host, lambda m: is_state(m) and m["game"])
host.send_json({"type": "leave_lobby"})
receive_until(host, lambda m: is_state(m) and m["lobby"] is None)
state = receive_until(guest, lambda m: is_state(m) and m["lobby"]["host_id"] != m["lobby"]["players"][0]["id"])
players = state["lobby"]["players"]
assert players[0]["is_ai"] and players[0]["name"] == "Cy"
assert state["lobby"]["host_id"] == players[1]["id"]
def test_ai_endpoint_requires_secret(client):
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("/internal/ai") as ws:
ws.receive_json()
def test_ai_endpoint_rejects_proxied_requests(client):
from starlette.websockets import WebSocketDisconnect
headers = {"Authorization": f"Bearer {SECRET}", "X-Forwarded-For": "203.0.113.7"}
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("/internal/ai", headers=headers) as ws:
ws.receive_json()
def test_ai_service_decides(client):
"""A fake AI service answers decide requests with the first legal action."""
requests = []
stop = threading.Event()
def fake_ai():
headers = {"Authorization": f"Bearer {SECRET}"}
with client.websocket_connect("/internal/ai", headers=headers) as ai:
ai.send_json({"type": "hello", "agent": "fake"})
while not stop.is_set():
message = ai.receive_json()
if message["type"] == "decide":
requests.append(message)
action = message["observation"]["legal_actions"][0]
ai.send_json({"type": "action", "request_id": message["request_id"], "action": action})
if len(requests) >= 3:
return
thread = threading.Thread(target=fake_ai, daemon=True)
thread.start()
host, _ = connect(client)
host.send_json({"type": "create_lobby", "name": "Eve"})
receive_until(host, lambda m: is_state(m) and m["lobby"] and m["lobby"]["ai_available"])
host.send_json({"type": "add_ai"})
host.send_json({"type": "add_ai"})
host.send_json({"type": "start_game"})
rng = random.Random(1)
while thread.is_alive():
message = host.receive_json()
if is_state(message) and message["game"]:
obs = message["game"]["observation"]
if obs["current_player"] == message["game"]["you"] and obs["legal_actions"]:
host.send_json({"type": "action", "action": rng.choice(obs["legal_actions"])})
stop.set()
assert len(requests) >= 3
assert all(r["observation"]["player"] in (1, 2) for r in requests)