With many simultaneous games, AI turns start in bursts and exceed the in-flight cap. That was counted as an AI failure, so after 5 bursts the breaker paused the AI for 30 s and every AI seat played random moves. - Only real failures (timeouts, lost connection, malformed/illegal answers, unexpected errors) count towards the breaker; the in-flight cap and "overloaded"/"expired" answers from the AI service do not - AI_MAX_INFLIGHT default 32 -> 64 (>= MAX_ACTIVE_GAMES, matches the AI service's AI_MAX_QUEUE) - Test that load shedding keeps the breaker closed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
4.0 KiB
Python
81 lines
4.0 KiB
Python
"""Server configuration, read from environment variables."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
|
|
def _float(name: str, default: float) -> float:
|
|
return float(os.environ.get(name, default))
|
|
|
|
|
|
def _int(name: str, default: int) -> int:
|
|
return int(os.environ.get(name, default))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Settings:
|
|
# Shared secret the AI service has to present on /internal/ai.
|
|
# Empty disables authentication (development only).
|
|
ai_shared_secret: str = os.environ.get("AI_SHARED_SECRET", "")
|
|
# Seconds to wait for the AI service before falling back to a random move.
|
|
ai_decision_timeout: float = _float("AI_DECISION_TIMEOUT", 5.0)
|
|
# Minimum "thinking" time of AI players so humans can follow the game.
|
|
ai_move_delay: float = _float("AI_MOVE_DELAY", 0.8)
|
|
# Pause after a completed trick before AI players continue.
|
|
trick_pause: float = _float("TRICK_PAUSE", 1.8)
|
|
# Sessions without a connection are dropped after this many seconds.
|
|
session_ttl: float = _float("SESSION_TTL", 1800.0)
|
|
# Maximum number of simultaneously existing lobbies.
|
|
max_lobbies: int = _int("MAX_LOBBIES", 1000)
|
|
# Comma separated list of allowed websocket origins; empty allows all.
|
|
allowed_origins: tuple[str, ...] = tuple(
|
|
origin.strip() for origin in os.environ.get("ALLOWED_ORIGINS", "").split(",") if origin.strip()
|
|
)
|
|
log_level: str = os.environ.get("LOG_LEVEL", "INFO")
|
|
|
|
# --- Abuse limits -------------------------------------------------------
|
|
# Open websocket connections in total and per client IP.
|
|
max_connections: int = _int("MAX_CONNECTIONS", 500)
|
|
max_connections_per_ip: int = _int("MAX_CONNECTIONS_PER_IP", 10)
|
|
# Messages per second a single connection may send (token bucket).
|
|
message_rate: float = _float("MESSAGE_RATE", 10.0)
|
|
message_burst: int = _int("MESSAGE_BURST", 30)
|
|
# Dropped messages after which a flooding connection is closed.
|
|
max_rate_violations: int = _int("MAX_RATE_VIOLATIONS", 50)
|
|
# Lobbies a single IP may create per minute.
|
|
lobby_create_rate: int = _int("LOBBY_CREATE_RATE", 10)
|
|
|
|
# --- Load control -------------------------------------------------------
|
|
# Games running at the same time; further game starts are refused.
|
|
max_active_games: int = _int("MAX_ACTIVE_GAMES", 50)
|
|
load_sample_interval: float = _float("LOAD_SAMPLE_INTERVAL", 1.0)
|
|
# Event loop lag (seconds) / CPU share of the available quota (0..1).
|
|
load_lag_busy: float = _float("LOAD_LAG_BUSY", 0.1)
|
|
load_lag_overload: float = _float("LOAD_LAG_OVERLOAD", 0.25)
|
|
# A single sample with this much lag means overload right away: when the
|
|
# server is that slow, even the load sampler runs rarely.
|
|
load_lag_severe: float = _float("LOAD_LAG_SEVERE", 1.0)
|
|
load_cpu_busy: float = _float("LOAD_CPU_BUSY", 0.7)
|
|
load_cpu_overload: float = _float("LOAD_CPU_OVERLOAD", 0.9)
|
|
# Share of CPU periods in which the container was throttled (cgroup v2).
|
|
load_throttle_overload: float = _float("LOAD_THROTTLE_OVERLOAD", 0.5)
|
|
# Consecutive overloaded samples to enter / good samples to leave overload.
|
|
load_overload_samples: int = _int("LOAD_OVERLOAD_SAMPLES", 3)
|
|
load_recover_samples: int = _int("LOAD_RECOVER_SAMPLES", 10)
|
|
# "normal", "busy" or "overloaded" to pin the level (testing only).
|
|
load_force_level: str = os.environ.get("LOAD_FORCE_LEVEL", "")
|
|
|
|
# --- AI service protection ----------------------------------------------
|
|
# Unanswered decide requests per AI connection before falling back.
|
|
# Each running game has at most one AI deciding at a time, so keep this at
|
|
# least MAX_ACTIVE_GAMES; 64 matches the AI service's AI_MAX_QUEUE.
|
|
ai_max_inflight: int = _int("AI_MAX_INFLIGHT", 64)
|
|
# Consecutive AI failures that pause AI requests for ai_breaker_cooldown seconds.
|
|
ai_breaker_failures: int = _int("AI_BREAKER_FAILURES", 5)
|
|
ai_breaker_cooldown: float = _float("AI_BREAKER_COOLDOWN", 30.0)
|
|
|
|
|
|
settings = Settings()
|