Files
Wizard/wizard_website/src/api/useWizard.ts
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

212 lines
6.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
Action,
ClientMessage,
GameOptions,
GameView,
Lobby,
ServerMessage,
ServerStatus,
TrickResult,
} from './types'
// 'busy': the server refused the connection because it is full.
export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'replaced' | 'busy'
export interface Toast {
id: number
message: string
}
// Per tab, so several tabs can act as different players; survives reloads.
const TOKEN_KEY = 'wizard.token'
const TRICK_DISPLAY_MS = 2200
const TOAST_MS = 4000
const MAX_RETRY_DELAY_MS = 10000
// Close code the server uses when the session was opened in another tab.
const CLOSE_REPLACED = 4000
// "Try Again Later": the server is full. Retry slowly so we don't add load.
const CLOSE_TRY_AGAIN_LATER = 1013
const BUSY_RETRY_DELAYS_MS = [15000, 30000, 60000]
function websocketUrl(): string {
const configured = import.meta.env.VITE_WS_URL as string | undefined
if (configured) return configured
// The API lives next to the website: /wizard/website/ -> /wizard/api/ws
// (and /api/ws in development).
const url = new URL('../api/ws', window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = ''
return url.toString()
}
function readToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
function storeToken(token: string) {
try {
sessionStorage.setItem(TOKEN_KEY, token)
} catch {
// Storage unavailable: reconnecting after a reload will start a new session.
}
}
export function useWizard() {
const [status, setStatus] = useState<ConnectionStatus>('connecting')
const [playerId, setPlayerId] = useState<string | null>(null)
const [lobby, setLobby] = useState<Lobby | null>(null)
const [game, setGame] = useState<GameView | null>(null)
// The most recently completed trick, shown for a moment before it is cleared.
const [completedTrick, setCompletedTrick] = useState<TrickResult | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
const [serverStatus, setServerStatus] = useState<ServerStatus>({ load: 'normal', accepting_games: true })
const socketRef = useRef<WebSocket | null>(null)
const toastId = useRef(0)
const showError = useCallback((message: string) => {
const id = ++toastId.current
setToasts((current) => [...current, { id, message }])
setTimeout(() => setToasts((current) => current.filter((toast) => toast.id !== id)), TOAST_MS)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id))
}, [])
useEffect(() => {
let disposed = false
let attempt = 0
let busyAttempt = 0
let retryTimer: ReturnType<typeof setTimeout> | undefined
let trickTimer: ReturnType<typeof setTimeout> | undefined
const handleMessage = (message: ServerMessage) => {
switch (message.type) {
case 'welcome':
storeToken(message.token)
setPlayerId(message.player_id)
break
case 'state': {
setLobby(message.lobby)
setGame(message.game)
const trick = message.events.find((event) => event.type === 'trick_completed')
if (trick) {
setCompletedTrick(trick.trick)
clearTimeout(trickTimer)
trickTimer = setTimeout(() => setCompletedTrick(null), TRICK_DISPLAY_MS)
}
if (!message.game) setCompletedTrick(null)
break
}
case 'server_status':
setServerStatus({ load: message.load, accepting_games: message.accepting_games })
break
case 'error':
showError(message.message)
break
}
}
const connect = () => {
const socket = new WebSocket(websocketUrl())
socketRef.current = socket
// A "busy" error before the welcome means the server refused this connection.
let welcomed = false
let refused = false
socket.onopen = () => {
attempt = 0
setStatus('open')
socket.send(JSON.stringify({ type: 'hello', token: readToken() } satisfies ClientMessage))
}
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data as string) as ServerMessage
if (message.type === 'welcome') {
welcomed = true
busyAttempt = 0
}
// The "server is full" error arrives right before the 1013 close; the
// banner explains it, so no toast.
if (message.type === 'error' && message.code === 'busy' && !welcomed) {
refused = true
return
}
handleMessage(message)
} catch (error) {
console.error('Invalid message from server', error)
}
}
socket.onclose = (event) => {
if (disposed) return
socketRef.current = null
if (event.code === CLOSE_REPLACED) {
setStatus('replaced')
return
}
let delay: number
if (event.code === CLOSE_TRY_AGAIN_LATER || refused) {
setStatus('busy')
delay = BUSY_RETRY_DELAYS_MS[Math.min(busyAttempt, BUSY_RETRY_DELAYS_MS.length - 1)]
busyAttempt += 1
} else {
setStatus('closed')
busyAttempt = 0
delay = Math.min(1000 * 2 ** attempt, MAX_RETRY_DELAY_MS)
attempt += 1
}
retryTimer = setTimeout(() => {
setStatus('connecting')
connect()
}, delay)
}
}
connect()
return () => {
disposed = true
clearTimeout(retryTimer)
clearTimeout(trickTimer)
socketRef.current?.close()
socketRef.current = null
}
}, [showError])
const send = useCallback(
(message: ClientMessage) => {
const socket = socketRef.current
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message))
} else {
showError('Not connected to the server')
}
},
[showError],
)
const actions = useMemo(
() => ({
createLobby: (name: string) => send({ type: 'create_lobby', name }),
joinLobby: (code: string, name: string) => send({ type: 'join_lobby', code, name }),
leaveLobby: () => send({ type: 'leave_lobby' }),
addAI: () => send({ type: 'add_ai' }),
removePlayer: (playerId: string) => send({ type: 'remove_player', player_id: playerId }),
setOptions: (options: GameOptions) => send({ type: 'set_options', ...options }),
startGame: () => send({ type: 'start_game' }),
play: (action: Action) => send({ type: 'action', action }),
returnToLobby: () => send({ type: 'return_to_lobby' }),
}),
[send],
)
return { status, playerId, lobby, game, completedTrick, serverStatus, toasts, dismissToast, actions }
}
export type WizardActions = ReturnType<typeof useWizard>['actions']