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:
@@ -10,10 +10,12 @@ const STATUS_MESSAGES = {
|
||||
connecting: 'Connecting to the server…',
|
||||
closed: 'Connection lost. Reconnecting…',
|
||||
replaced: 'This game was opened in another window. Reload the page to continue here.',
|
||||
busy: 'The server is full right now. Retrying in a moment…',
|
||||
} as const
|
||||
|
||||
function App() {
|
||||
const { status, playerId, lobby, game, completedTrick, toasts, dismissToast, actions } = useWizard()
|
||||
const { status, playerId, lobby, game, completedTrick, serverStatus, toasts, dismissToast, actions } =
|
||||
useWizard()
|
||||
const lobbyCode = lobby?.code
|
||||
|
||||
// Keep the lobby code in the address bar so the URL can be shared as an invite link.
|
||||
@@ -27,9 +29,22 @@ function App() {
|
||||
|
||||
let screen
|
||||
if (!lobby || !playerId) {
|
||||
screen = <HomeScreen connected={status === 'open'} actions={actions} />
|
||||
screen = (
|
||||
<HomeScreen
|
||||
connected={status === 'open'}
|
||||
acceptingGames={serverStatus.accepting_games}
|
||||
actions={actions}
|
||||
/>
|
||||
)
|
||||
} else if (lobby.status === 'waiting' || !game) {
|
||||
screen = <LobbyScreen lobby={lobby} playerId={playerId} actions={actions} />
|
||||
screen = (
|
||||
<LobbyScreen
|
||||
lobby={lobby}
|
||||
playerId={playerId}
|
||||
acceptingGames={serverStatus.accepting_games}
|
||||
actions={actions}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
screen = (
|
||||
<GameScreen
|
||||
|
||||
@@ -103,10 +103,22 @@ export type GameEvent =
|
||||
| { type: 'round_started'; round_number: number; dealer: number; trump_card: Card | null }
|
||||
| { type: 'game_finished'; scores: number[]; winners: number[] }
|
||||
|
||||
export type LoadLevel = 'normal' | 'busy' | 'overloaded'
|
||||
|
||||
export interface ServerStatus {
|
||||
load: LoadLevel
|
||||
// False when the server refuses new lobbies/games (overloaded or full).
|
||||
accepting_games: boolean
|
||||
}
|
||||
|
||||
// Machine readable error codes: 'busy' (no capacity) or 'rate_limited'.
|
||||
export type ErrorCode = 'busy' | 'rate_limited'
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: 'welcome'; player_id: string; token: string; name: string | null }
|
||||
| { type: 'state'; lobby: Lobby | null; game: GameView | null; events: GameEvent[] }
|
||||
| { type: 'error'; message: string }
|
||||
| ({ type: 'server_status' } & ServerStatus)
|
||||
| { type: 'error'; message: string; code?: ErrorCode }
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'hello'; token: string | null }
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user