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>
92 lines
2.4 KiB
Python
92 lines
2.4 KiB
Python
"""Messages exchanged with web clients over the ``/ws`` websocket.
|
|
|
|
Client -> server messages are validated with pydantic. Server -> client
|
|
messages are plain dicts:
|
|
|
|
- ``{"type": "welcome", "player_id", "token", "name"}``
|
|
- ``{"type": "state", "lobby": LobbyView | null, "game": GameView | null, "events": [...]}``
|
|
- ``{"type": "error", "message", "code"?}``
|
|
- ``{"type": "server_status", "load", "accepting_games"}``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated, Any, Literal
|
|
|
|
from pydantic import BaseModel, Field, StringConstraints, TypeAdapter
|
|
|
|
PlayerName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=20)]
|
|
LobbyCode = Annotated[str, StringConstraints(strip_whitespace=True, to_upper=True, min_length=4, max_length=12)]
|
|
|
|
|
|
class Hello(BaseModel):
|
|
type: Literal["hello"]
|
|
# Token of a previous session, used to reconnect after a page reload.
|
|
token: str | None = None
|
|
|
|
|
|
class CreateLobby(BaseModel):
|
|
type: Literal["create_lobby"]
|
|
name: PlayerName
|
|
|
|
|
|
class JoinLobby(BaseModel):
|
|
type: Literal["join_lobby"]
|
|
code: LobbyCode
|
|
name: PlayerName
|
|
|
|
|
|
class LeaveLobby(BaseModel):
|
|
type: Literal["leave_lobby"]
|
|
|
|
|
|
class AddAI(BaseModel):
|
|
type: Literal["add_ai"]
|
|
|
|
|
|
class RemovePlayer(BaseModel):
|
|
type: Literal["remove_player"]
|
|
player_id: str
|
|
|
|
|
|
class SetOptions(BaseModel):
|
|
type: Literal["set_options"]
|
|
hidden_bids: bool
|
|
|
|
|
|
class StartGame(BaseModel):
|
|
type: Literal["start_game"]
|
|
|
|
|
|
class GameAction(BaseModel):
|
|
type: Literal["action"]
|
|
action: dict[str, Any]
|
|
|
|
|
|
class ReturnToLobby(BaseModel):
|
|
type: Literal["return_to_lobby"]
|
|
|
|
|
|
ClientMessage = Annotated[
|
|
Hello | CreateLobby | JoinLobby | LeaveLobby | AddAI | RemovePlayer
|
|
| SetOptions | StartGame | GameAction | ReturnToLobby,
|
|
Field(discriminator="type"),
|
|
]
|
|
|
|
client_message_adapter: TypeAdapter[ClientMessage] = TypeAdapter(ClientMessage)
|
|
|
|
|
|
# Machine readable error codes (the frontend reacts to them).
|
|
ERROR_BUSY = "busy" # server has no capacity for this right now
|
|
ERROR_RATE_LIMITED = "rate_limited" # client sends too many messages / creates too many lobbies
|
|
|
|
# Websocket close code "Try Again Later" (RFC 6455 registry), used when full.
|
|
CLOSE_TRY_AGAIN_LATER = 1013
|
|
|
|
|
|
def error(message: str, code: str | None = None) -> dict:
|
|
message_dict: dict = {"type": "error", "message": message}
|
|
if code:
|
|
message_dict["code"] = code
|
|
return message_dict
|