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
+38 -6
View File
@@ -6,10 +6,12 @@ import type {
GameView,
Lobby,
ServerMessage,
ServerStatus,
TrickResult,
} from './types'
export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'replaced'
// 'busy': the server refused the connection because it is full.
export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'replaced' | 'busy'
export interface Toast {
id: number
@@ -23,6 +25,9 @@ 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
@@ -59,6 +64,7 @@ export function useWizard() {
// 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)
@@ -76,6 +82,7 @@ export function useWizard() {
useEffect(() => {
let disposed = false
let attempt = 0
let busyAttempt = 0
let retryTimer: ReturnType<typeof setTimeout> | undefined
let trickTimer: ReturnType<typeof setTimeout> | undefined
@@ -97,6 +104,9 @@ export function useWizard() {
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
@@ -107,6 +117,9 @@ export function useWizard() {
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')
@@ -114,7 +127,18 @@ export function useWizard() {
}
socket.onmessage = (event) => {
try {
handleMessage(JSON.parse(event.data as string) as ServerMessage)
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)
}
@@ -126,9 +150,17 @@ export function useWizard() {
setStatus('replaced')
return
}
setStatus('closed')
const delay = Math.min(1000 * 2 ** attempt, MAX_RETRY_DELAY_MS)
attempt += 1
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()
@@ -173,7 +205,7 @@ export function useWizard() {
[send],
)
return { status, playerId, lobby, game, completedTrick, toasts, dismissToast, actions }
return { status, playerId, lobby, game, completedTrick, serverStatus, toasts, dismissToast, actions }
}
export type WizardActions = ReturnType<typeof useWizard>['actions']