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>
This commit is contained in:
2026-09-27 12:51:46 +02:00
co-authored by Claude Opus 5.5
parent 905574e8f1
commit 87dfcd55b7
33 changed files with 1591 additions and 128 deletions
@@ -104,3 +104,13 @@
color: var(--text-muted);
font-size: 13px;
}
.busy {
margin: 0;
padding: 10px 12px;
border: 1px solid rgb(255 209 102 / 40%);
border-radius: 10px;
background: rgb(255 209 102 / 8%);
color: var(--gold);
font-size: 14px;
}
@@ -20,10 +20,12 @@ function initialCode(): string {
interface HomeScreenProps {
connected: boolean
// False while the server refuses new games (overloaded or full).
acceptingGames: boolean
actions: WizardActions
}
export function HomeScreen({ connected, actions }: HomeScreenProps) {
export function HomeScreen({ connected, acceptingGames, actions }: HomeScreenProps) {
const [name, setName] = useState(initialName)
const [code, setCode] = useState(initialCode)
const trimmedName = name.trim()
@@ -73,9 +75,20 @@ export function HomeScreen({ connected, actions }: HomeScreenProps) {
onChange={(event) => setName(event.target.value)}
/>
<Button size="lg" className={styles.full} disabled={!connected || !trimmedName} onClick={create}>
<Button
size="lg"
className={styles.full}
disabled={!connected || !trimmedName || !acceptingGames}
onClick={create}
>
Create lobby
</Button>
{connected && !acceptingGames && (
<p className={styles.busy} role="status">
The server is busy right now, so new games can't be created. You can still join an existing
lobby — please try again in a moment.
</p>
)}
<div className={styles.divider}>
<span>or join a lobby</span>
@@ -211,3 +211,13 @@
color: var(--text-muted);
font-size: 14px;
}
.busy {
margin: 0;
padding: 12px 16px;
border: 1px solid rgb(255 209 102 / 40%);
border-radius: 12px;
background: rgb(255 209 102 / 8%);
color: var(--gold);
font-size: 14px;
}
@@ -7,6 +7,8 @@ import styles from './LobbyScreen.module.css'
interface LobbyScreenProps {
lobby: Lobby
playerId: string
// False while the server refuses new games (overloaded or full).
acceptingGames: boolean
actions: WizardActions
}
@@ -17,7 +19,7 @@ function inviteLink(code: string): string {
return url.toString()
}
export function LobbyScreen({ lobby, playerId, actions }: LobbyScreenProps) {
export function LobbyScreen({ lobby, playerId, acceptingGames, actions }: LobbyScreenProps) {
const [copied, setCopied] = useState<'code' | 'link' | null>(null)
const isHost = lobby.host_id === playerId
const isFull = lobby.players.length >= lobby.max_players
@@ -116,13 +118,24 @@ export function LobbyScreen({ lobby, playerId, actions }: LobbyScreenProps) {
</label>
</section>
{!acceptingGames && (
<p className={styles.busy} role="status">
The server is busy right now, so games can't be started. Your lobby stays open — try again in a
moment.
</p>
)}
<footer className={styles.actions}>
<Button variant="danger" onClick={actions.leaveLobby}>
Leave lobby
</Button>
{isHost ? (
<Button size="lg" disabled={missing > 0} onClick={actions.startGame}>
{missing > 0 ? `Need ${missing} more player${missing > 1 ? 's' : ''}` : 'Start game'}
<Button size="lg" disabled={missing > 0 || !acceptingGames} onClick={actions.startGame}>
{missing > 0
? `Need ${missing} more player${missing > 1 ? 's' : ''}`
: acceptingGames
? 'Start game'
: 'Server busy'}
</Button>
) : (
<p className={styles.waiting}>Waiting for the host to start the game…</p>