Files
Wizard/docs/04-server.md
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

413 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 4. Game server (`wizard_server/app`)
A FastAPI application running in a single uvicorn process. It keeps
**everything in memory**: sessions, lobbies and running games.
## Module overview
```mermaid
flowchart TB
main["main.py<br/>routes, auth"] --> service["service.py<br/>WizardService"]
service --> sessions["sessions.py<br/>Session, Connection, SessionRegistry"]
service --> lobby["lobby.py<br/>LobbyManager, Lobby, Seat"]
service --> protocol["protocol.py<br/>message schema"]
service --> bridge["ai_bridge.py<br/>AIBridge"]
lobby --> bridge
lobby --> engine[("wizard_game")]
bridge --> engine
main --> config["config.py<br/>Settings"]
```
| Module | Main types | Role |
|--------|-----------|------|
| `main.py` | `create_app()` | builds the FastAPI app, defines the three routes, starts the cleanup task |
| `config.py` | `Settings`, `settings` | configuration from environment variables |
| `protocol.py` | pydantic models, `client_message_adapter` | validates messages from browsers |
| `service.py` | `WizardService` | the "controller": handles a browser connection, dispatches messages, manages sessions and cleanup |
| `sessions.py` | `Session`, `Connection`, `SessionRegistry` | player identity across reconnects; ordered sending |
| `lobby.py` | `LobbyManager`, `Lobby`, `Seat`, `LobbyError` | lobby membership, host actions, running the game, AI turns, broadcasting |
| `ai_bridge.py` | `AIBridge`, `AIConnection` | talks to the AI service; random fallback, in-flight cap, circuit breaker |
| `limits.py` | `TokenBucket`, `KeyedRateLimiter`, `ConnectionLimiter` | abuse limits (message rate, connections, lobby creation) |
| `load.py` | `LoadMonitor`, `next_level`, cgroup helpers | detects when the server runs out of compute |
## Routes (`main.py`)
Caddy strips `/wizard/api`, so these are the paths the server sees:
| Route | Used by | Purpose |
|-------|---------|---------|
| `GET /health` | Docker health check, you | `{"status":"ok","lobbies":…,"sessions":…,"ai_available":…}` |
| `WS /ws` | browsers | the game protocol (see [protocol](05-protocol.md)) |
| `WS /internal/ai` | AI service | decision requests |
`/internal/ai` is protected twice:
1. `_via_proxy()` rejects the connection if it carries forwarding headers
(`X-Forwarded-For`, `Forwarded`, `Via`). Caddy always adds them, so
requests from the internet via `…/wizard/api/internal/ai` are refused. The
AI service connects directly over the internal network and has none.
2. `_ai_authorized()` requires `Authorization: Bearer <AI_SHARED_SECRET>`
(compared with `secrets.compare_digest`). If the secret is empty,
authentication is disabled — only for development; a warning is logged at
startup.
`create_app(config)` takes a `Settings` object so tests can build apps with
different configuration (e.g. zero delays).
## Core concepts
### Session — "who is this browser tab?"
`sessions.Session`:
| Field | Meaning |
|-------|---------|
| `player_id` | public id (12 hex chars), shown to other players, used as seat id |
| `token` | secret (`secrets.token_urlsafe(24)`), stored by the browser in `sessionStorage`, used to reconnect |
| `name` | display name, set when creating/joining a lobby |
| `lobby_code` | lobby the player is in, or `None` |
| `connection` | the current `Connection`, or `None` while disconnected |
| `last_seen` | monotonic time of the last disconnect/connect, for expiry |
`SessionRegistry` maps token → session. Sessions without a connection for
`SESSION_TTL` seconds (default 30 min) are removed by the cleanup task; if they
were in a lobby they leave it (see below).
### Connection — ordered sending
`sessions.Connection` wraps one websocket and owns an `asyncio.Queue`. A
background writer task sends queued messages one by one. Therefore:
- `session.send(msg)` is **synchronous** (just enqueues), so game logic never
awaits while mutating state;
- messages to one client are always in order and never sent concurrently.
### Lobby and seats
`lobby.Seat` = one place at the table:
| Field | Meaning |
|-------|---------|
| `player_id` | session's player id, or `ai-xxxxxxxx` for AI seats |
| `name` | unique within the lobby (`"Ann"`, `"Ann (2)"`, …) |
| `is_ai` | AI seats are played via the `AIBridge` |
| `session` | the human's session (or `None`) |
The **seat index in `lobby.seats` is the player index in the game**. Seats are
never removed or reordered while a game runs, so indices stay valid.
`lobby.Lobby` fields: `code` (6 chars from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, no
0/O/1/I), `seats`, `host_id`, `options` (`GameOptions`), `status`
(`waiting` → `playing` → `finished` → back to `waiting`), `game` (`WizardGame`
or `None`).
`LobbyManager` creates lobbies (up to `MAX_LOBBIES`), finds them by code
(case-insensitive) and deletes a lobby as soon as no human seat is left
(`discard_if_abandoned`).
## Lifecycle of a browser connection (`WizardService.handle_client`)
```mermaid
sequenceDiagram
participant B as Browser
participant S as WizardService
B->>S: websocket connect /ws
Note over S: Origin check (ALLOWED_ORIGINS), accept,<br/>start Connection writer
B->>S: {"type":"hello","token": <stored token or null>}
alt token known
S->>S: reuse session (kick an older tab with close code 4000)
else
S->>S: create new session
end
S-->>B: {"type":"welcome","player_id","token","name"}
S-->>B: {"type":"state", …} (lobby + game if still seated, else nulls)
loop every message
B->>S: create_lobby / join_lobby / action / …
S->>S: validate (pydantic) → _dispatch → Lobby method
S-->>B: state broadcast, or {"type":"error"}
end
B--xS: disconnect
Note over S: session.connection = None, lobby broadcast<br/>(seat shows "offline"), the seat is kept
```
Rules applied in `_hello` and `_dispatch`:
- Every message except `hello` requires a completed `hello` on *this*
connection.
- Invalid JSON/fields → `error` message with the pydantic reason; the
connection stays open.
- `LobbyError` (rule violations like "Only the host can do that") → `error`
message to that client only.
- Opening the same session in a second tab takes it over: the old tab gets an
`error` and is closed with code **4000**; the frontend then stops
reconnecting and shows "opened in another window".
### Message handling (`_dispatch`)
| Message | What happens |
|---------|-------------|
| `create_lobby {name}` | leave current lobby, set name, create lobby, join it (creator becomes host) |
| `join_lobby {code, name}` | lobby must exist; leave another lobby first; join. Joining is only possible while the lobby is `waiting` and has < 6 seats — **except** re-joining your own seat |
| `leave_lobby` | leave (see below), receive empty state |
| `add_ai` | host only, lobby waiting and not full; adds an AI seat named after a famous wizard (Merlin, Morgana, …) |
| `remove_player {player_id}` | host only, lobby waiting; the removed human gets an error + empty state |
| `set_options {hidden_bids}` | host only, lobby waiting |
| `start_game` | host only, 3–6 seats; creates `WizardGame(len(seats), options)` |
| `action {action}` | lobby must be `playing`; the engine validates the action |
| `return_to_lobby` | host only, game must be finished; status back to `waiting`, game discarded |
### Leaving
`Lobby.leave(session)`:
- In a **waiting** lobby the seat is removed.
- During a **playing or finished** game the seat is kept and **converted to an
AI seat** (new `ai-…` id, same name), so the game continues.
- If the host leaves, the next human seat becomes host.
- If no human seat remains, the lobby is deleted (its AI task is cancelled).
Disconnecting (closing the tab, network loss) is *not* leaving: the seat stays
human and the game waits for that player. After `SESSION_TTL` without
reconnect, the cleanup task makes the session leave (→ AI takes over).
## Running a game
### Broadcasting state
`Lobby.broadcast(events)` sends every seated human their own message built by
`Lobby.state_for(session, events)`:
```json
{
"type": "state",
"lobby": { "code": "…", "status": "playing", "host_id": "…", "options": {…},
"players": [{"id", "name", "is_ai", "connected"}, …],
"min_players": 3, "max_players": 6, "ai_available": true },
"game": { "you": 1, "observation": { … personal Observation … } },
"events": [ … events caused by the last action … ]
}
```
It is called after every change (join, leave, options, start, every move, AI
service connecting/disconnecting, a player going offline).
### Human moves
`Lobby.apply_action(session, action_dict)` → `action_from_dict` →
`game.apply(seat, action)`. Errors become `LobbyError` → error toast in the
browser. On success `_after_action(events)` runs.
### `_after_action` and the AI loop
```python
def _after_action(self, events):
if TrickCompleted in events: self._pause_until = now + TRICK_PAUSE
if GameFinished in events: self.status = FINISHED
self.broadcast(events)
self._schedule_ai()
```
`_schedule_ai()` starts `_ai_loop()` as an asyncio task if the current player
is an AI seat and no loop is running. The loop:
```mermaid
flowchart TD
A{"current player<br/>is an AI seat?"} -- no --> Z[stop]
A -- yes --> B["obs = game.observation(seat)"]
B --> C["action = await bridge.decide(obs)"]
C --> D["wait until max(start + AI_MOVE_DELAY, pause_until)"]
D --> E{"same game, same seat,<br/>action still legal?"}
E -- no --> A
E -- yes --> F["game.apply → _after_action"]
F --> A
```
- The AI "thinks" in parallel with the minimum delay, so a slow AI does not add
to the delay.
- `TRICK_PAUSE` gives humans time to see a completed trick before AI players
continue.
- The re-validation step makes the loop safe against state changes while it was
awaiting (e.g. the host returned to the lobby).
- Several consecutive AI seats are handled by the same loop.
### Concurrency model
Everything runs on one asyncio event loop. State changes (join, apply action,
…) are **synchronous** — there is no `await` between checking and mutating —
so no locks are needed. The only awaits are in the AI loop (decide, sleep), and
it re-validates afterwards. This is also why the server must run with **one
worker process**: a second process would have its own, separate lobbies.
## AI bridge (`ai_bridge.py`)
The AI service keeps a websocket open to `/internal/ai`. `AIBridge`:
- keeps a list of connected AI services (`available` = at least one);
- `decide(observation)`: picks a connection round robin, sends
`{"type":"decide","request_id","observation"}`, and awaits a future that
`serve()` resolves when the matching reply arrives;
- falls back to a **random legal action** if no service is connected, the reply
does not arrive within `AI_DECISION_TIMEOUT`, the service reports an error,
the action is malformed or illegal (logged as warning, except the
"no service connected" case which is logged at debug level);
- calls `on_change` when a service connects/disconnects, and the service then
re-broadcasts all lobbies so the lobby screen shows whether the AI is online;
- sends `timeout` with every request so the AI service can drop requests the
server has already given up on;
- **in-flight cap**: if `AI_MAX_INFLIGHT` requests are already waiting on a
connection, the move is played by the fallback without asking;
- **circuit breaker**: after `AI_BREAKER_FAILURES` failures in a row (timeouts,
errors, illegal answers) the AI is not asked for `AI_BREAKER_COOLDOWN`
seconds, so a struggling AI service is not buried under more requests.
## Limits and overload handling
The server protects the host and its players in three layers. The guiding rule
is: **running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.**
```mermaid
flowchart TD
C[new websocket] --> L1{"connection limit<br/>total / per IP"}
L1 -- over --> R1["error busy + close 1013"]
L1 -- ok --> M[message]
M --> L2{"message rate<br/>token bucket"}
L2 -- over --> R2["dropped + error rate_limited<br/>(close 1008 when flooding)"]
L2 -- ok --> D{"create_lobby /<br/>start_game?"}
D -- no --> OK[handled normally]
D -- yes --> L3{"accepting_games?<br/>not overloaded and<br/>running games < MAX_ACTIVE_GAMES"}
L3 -- no --> R3["error busy"]
L3 -- yes --> OK
```
### 1. Hard limits (Docker and uvicorn)
- `compose.yaml` caps every container (`deploy.resources.limits` + `pids_limit`):
server 1 CPU / 512 MB, AI 1 CPU / 1 GB, website 0.25 CPU / 64 MB by default,
overridable in `.env` (`SERVER_CPUS`, `SERVER_MEMORY`, …).
- uvicorn (`wizard_server/Dockerfile`): `--ws-max-size 65536` (bigger messages
close the connection with 1009), `--ws-max-queue 16`,
`--limit-concurrency 1000` (HTTP 503 beyond that — a last resort above the
app's own connection limit).
### 2. Abuse limits (`limits.py`, applied in `WizardService`)
| Limit | Default | When exceeded |
|-------|---------|---------------|
| open connections total / per client IP | 500 / 10 | `error` (code `busy`), then close **1013** "Try Again Later" |
| messages per connection (token bucket) | 10/s, burst 30 | message dropped, `error` (code `rate_limited`, not repeated for every message); after 50 drops the connection is closed with 1008 |
| lobbies created per client IP | 10 per minute | `error` (code `rate_limited`) |
The client IP is `websocket.client.host`. Behind Caddy, uvicorn's
`--proxy-headers` sets it from `X-Forwarded-For` (Caddy does not pass on
client-supplied values, so it cannot be spoofed).
### 3. Load monitor and admission control (`load.py`)
`LoadMonitor.run()` (started in the app lifespan) samples once per second:
- **event loop lag** — how late the loop wakes up; the most direct "players
notice slowness" signal;
- **CPU** — process CPU time relative to the container's CPU quota (read from
the process's own cgroup, `/proc/self/cgroup` → `…/cpu.max`), at most one
core because game logic runs on one core;
- **throttling** — share of CPU periods in which the container hit its limit
(`cpu.stat`: `nr_throttled / nr_periods`).
Each sample is classified (`classify`) and `next_level` derives the level with
hysteresis:
| Level | Entered when | Left when |
|-------|--------------|-----------|
| `busy` | one sample with lag ≥ 0.1 s or CPU ≥ 70 % | 10 normal samples in a row |
| `overloaded` | 3 samples in a row with lag ≥ 0.25 s, CPU ≥ 90 % or throttled ≥ 50 % — or **one** sample with lag ≥ 1 s | 10 samples in a row that are not overloaded |
`busy` is only informational. While **overloaded**, or while
`MAX_ACTIVE_GAMES` (default 50) games are running, `accepting_games` is false:
| Action | Refused when not accepting games |
|--------|----------------------------------|
| `create_lobby` | yes (`error` code `busy`) |
| `start_game` | yes (`error` code `busy`) |
| `join_lobby`, moves, `return_to_lobby`, reconnects | never — they are cheap and keep running games alive |
Refusing new games sheds load by itself: finished games are not replaced, so
the load drops and the server recovers.
Every connected client receives a `server_status` message after `welcome` and
whenever the level or `accepting_games` changes (the website then disables
"Create lobby" / "Start game" and shows a notice). `/health` reports `load`,
`loop_lag`, `cpu`, `throttled`, `accepting_games`, `active_games`,
`connections` and AI bridge stats, but its `status` **stays "ok"** under load:
an overloaded server must never be restarted by Docker, which would end all
running games.
Measured on a server capped at 15 % of a CPU with 30 games played at full
speed: overloaded after ~3 s, new lobbies refused, running games kept going,
back to normal ~10 s after the load dropped.
For manual testing, `LOAD_FORCE_LEVEL=overloaded` pins the level.
## Configuration (`config.py`)
All values are read from environment variables when the module is imported.
| Variable | Default | Meaning |
|----------|---------|---------|
| `AI_SHARED_SECRET` | empty | secret the AI service must send; empty = no auth (dev only) |
| `AI_DECISION_TIMEOUT` | 5.0 s | wait for the AI before playing a random move |
| `AI_MOVE_DELAY` | 0.8 s | minimum time an AI move takes, so humans can follow |
| `TRICK_PAUSE` | 1.8 s | pause after a completed trick before AIs continue |
| `SESSION_TTL` | 1800 s | disconnected sessions are dropped after this |
| `MAX_LOBBIES` | 1000 | limit of simultaneously open lobbies |
| `ALLOWED_ORIGINS` | empty (= all) | comma separated allowed `Origin` headers for `/ws` |
| `LOG_LEVEL` | INFO | Python logging level |
| `MAX_CONNECTIONS` / `MAX_CONNECTIONS_PER_IP` | 500 / 10 | open websocket connections |
| `MESSAGE_RATE` / `MESSAGE_BURST` | 10 / 30 | messages per second per connection |
| `MAX_RATE_VIOLATIONS` | 50 | dropped messages before a flooding connection is closed |
| `LOBBY_CREATE_RATE` | 10 | lobbies per IP per minute |
| `MAX_ACTIVE_GAMES` | 50 | games running at the same time |
| `LOAD_SAMPLE_INTERVAL` | 1.0 s | load sampling period |
| `LOAD_LAG_BUSY` / `LOAD_LAG_OVERLOAD` / `LOAD_LAG_SEVERE` | 0.1 / 0.25 / 1.0 s | event loop lag thresholds |
| `LOAD_CPU_BUSY` / `LOAD_CPU_OVERLOAD` | 0.7 / 0.9 | CPU share of the quota |
| `LOAD_THROTTLE_OVERLOAD` | 0.5 | throttled share of CPU periods |
| `LOAD_OVERLOAD_SAMPLES` / `LOAD_RECOVER_SAMPLES` | 3 / 10 | hysteresis |
| `LOAD_FORCE_LEVEL` | empty | pin the level (testing) |
| `AI_MAX_INFLIGHT` | 32 | waiting AI requests per AI connection |
| `AI_BREAKER_FAILURES` / `AI_BREAKER_COOLDOWN` | 5 / 30 s | AI circuit breaker |
## Housekeeping
`WizardService.cleanup_loop()` runs every 30 s (started in the app lifespan)
and removes expired sessions (leaving their lobby first) and forgets idle
per-IP lobby-creation buckets.
## Tests (`wizard_server/tests/`)
Uses FastAPI's `TestClient` websockets with zero delays and a raised message
rate limit (`helpers.TEST_SETTINGS`).
- `test_server.py` — health, hello requirement, validation errors, the full
lobby flow including a complete 20-round game between two humans and an AI,
reconnect by token, leaving mid-game (seat becomes AI, host moves), the AI
endpoint's secret and proxy checks, and a fake AI service.
- `test_protection.py` — message flood, connections per IP, lobby creation
rate, forced overload, `MAX_ACTIVE_GAMES`, a running game continuing while
overloaded, `server_status` messages.
- `test_limits.py` — unit tests of the token bucket, limiters, load levels,
cgroup parsing and the AI circuit breaker / in-flight cap.
Fixtures (`conftest.py`): `client` (default test settings) and
`make_client(**overrides)` for custom settings. Enter websocket sessions with
`client.sockets.enter_context(client.websocket_connect(...))` (as
`helpers.connect` does), otherwise the test client hangs on shutdown.
## Limitations to be aware of
- In-memory state: restarting the server ends all lobbies and games.
- Must run as a single process (no `--workers`).
- No spectators: only seated players receive game state.
- A running game whose humans all disconnected keeps its `MAX_ACTIVE_GAMES`
slot until their sessions expire (`SESSION_TTL`), because they may still
reconnect. It costs no CPU while waiting.