diff --git a/backend/.env.example b/backend/.env.example index ea8390e..eaa6c4e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,10 +3,10 @@ HLL_BACKEND_PORT=8000 HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 HLL_BACKEND_REFRESH_INTERVAL_SECONDS=120 -HLL_BACKEND_LIVE_DATA_SOURCE=a2s -HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard -HLL_BACKEND_RCON_TIMEOUT_SECONDS=10 -HLL_BACKEND_RCON_TARGETS= +HLL_BACKEND_LIVE_DATA_SOURCE=rcon +HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon +HLL_BACKEND_RCON_TIMEOUT_SECONDS=20 +HLL_BACKEND_RCON_TARGETS=[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #03","slug":"comunidad-hispana-03","external_server_id":"comunidad-hispana-03","host":"5.196.78.45","port":27230,"password":"replace-me-03","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}] HLL_HISTORICAL_CRCON_PAGE_SIZE=50 HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS=15 HLL_HISTORICAL_CRCON_DETAIL_WORKERS=8 diff --git a/backend/app/config.py b/backend/app/config.py index 66838b6..215ef79 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3" DEFAULT_REFRESH_INTERVAL_SECONDS = 300 DEFAULT_LIVE_DATA_SOURCE = "rcon" DEFAULT_HISTORICAL_DATA_SOURCE = "rcon" -DEFAULT_RCON_TIMEOUT_SECONDS = 10.0 +DEFAULT_RCON_TIMEOUT_SECONDS = 20.0 DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50 DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0 DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS = 8 diff --git a/backend/app/rcon_client.py b/backend/app/rcon_client.py index 00feeef..ce9ead6 100644 --- a/backend/app/rcon_client.py +++ b/backend/app/rcon_client.py @@ -7,6 +7,7 @@ import itertools import json import socket import struct +from collections.abc import Mapping from dataclasses import dataclass from .config import ( @@ -36,6 +37,14 @@ class RconServerTarget: query_port: int | None = None +class RconQueryError(RuntimeError): + """Normalized RCON query failure with a machine-readable error type.""" + + def __init__(self, error_type: str, message: str) -> None: + super().__init__(message) + self.error_type = error_type + + class HllRconConnection: """Synchronous HLL RCON v2 connection for lightweight live status queries.""" @@ -203,12 +212,35 @@ def query_live_server_sample( ) -> dict[str, object]: """Query one HLL server and return both normalized and raw session data.""" resolved_timeout = timeout_seconds or get_rcon_request_timeout_seconds() - with HllRconConnection(timeout_seconds=resolved_timeout) as connection: - connection.connect(host=target.host, port=target.port, password=target.password) - session = connection.execute_json( - "GetServerInformation", - {"Name": "session", "Value": ""}, - ) + try: + with HllRconConnection(timeout_seconds=resolved_timeout) as connection: + connection.connect(host=target.host, port=target.port, password=target.password) + session = connection.execute_json( + "GetServerInformation", + {"Name": "session", "Value": ""}, + ) + except RconQueryError: + raise + except (TimeoutError, socket.timeout) as error: + raise RconQueryError( + "timeout", + f"Timed out after {resolved_timeout:.1f}s while querying {target.host}:{target.port}.", + ) from error + except ConnectionRefusedError as error: + raise RconQueryError( + "connection-refused", + f"Connection refused by {target.host}:{target.port}.", + ) from error + except OSError as error: + raise RconQueryError( + _classify_socket_error_type(error), + f"RCON socket error against {target.host}:{target.port}: {error}", + ) from error + except RuntimeError as error: + raise RconQueryError( + _classify_runtime_error_type(error), + str(error), + ) from error resolved_external_id = target.external_server_id or f"rcon:{target.host}:{target.port}" return { @@ -250,17 +282,19 @@ def build_rcon_target_key(target: RconServerTarget) -> str: def _coerce_rcon_target(raw_target: dict[str, object]) -> RconServerTarget: - name = str(raw_target.get("name") or "Unnamed RCON target").strip() - host = str(raw_target.get("host") or "").strip() - password = str(raw_target.get("password") or "").strip() - source_name = str(raw_target.get("source_name") or DEFAULT_RCON_SOURCE_NAME).strip() - port = int(raw_target.get("port") or 0) + slug = _string_or_none(raw_target.get("slug")) + external_server_id = _string_or_none(raw_target.get("external_server_id")) or slug + name = _string_or_none(raw_target.get("name")) or _slug_to_display_name(slug) or "Unnamed RCON target" + host = _required_string(raw_target, "host") + password = _required_string(raw_target, "password") + source_name = _string_or_none(raw_target.get("source_name")) or DEFAULT_RCON_SOURCE_NAME + port = _required_positive_int(raw_target, "port") if not host: - raise ValueError("Each RCON target must define a non-empty host.") + raise ValueError("Each RCON target must define a non-empty 'host'.") if port <= 0: - raise ValueError("Each RCON target must define a valid port.") + raise ValueError("Each RCON target must define a positive 'port'.") if not password: - raise ValueError("Each RCON target must define a non-empty password.") + raise ValueError("Each RCON target must define a non-empty 'password'.") return RconServerTarget( name=name, @@ -268,7 +302,7 @@ def _coerce_rcon_target(raw_target: dict[str, object]) -> RconServerTarget: port=port, password=password, source_name=source_name or DEFAULT_RCON_SOURCE_NAME, - external_server_id=_string_or_none(raw_target.get("external_server_id")), + external_server_id=external_server_id, region=_string_or_none(raw_target.get("region")), game_port=_coerce_optional_positive_int(raw_target.get("game_port")), query_port=_coerce_optional_positive_int(raw_target.get("query_port")), @@ -280,6 +314,11 @@ def _raise_for_status(response: dict[str, object], *, command_name: str) -> None if status_code == 200: return status_message = _string_or_none(response.get("statusMessage")) or "Unknown RCON error." + if command_name == "Login" and status_code in {401, 403}: + raise RconQueryError( + "auth/login", + f"{command_name} failed with RCON status {status_code}: {status_message}", + ) raise RuntimeError(f"{command_name} failed with RCON status {status_code}: {status_message}") @@ -313,3 +352,64 @@ def _coerce_optional_positive_int(value: object) -> int | None: if coerced <= 0: raise ValueError("Configured RCON target ports must be positive when defined.") return coerced + + +def _required_string(raw_target: Mapping[str, object], field_name: str) -> str: + value = _string_or_none(raw_target.get(field_name)) + if value is None: + available_fields = ", ".join(sorted(raw_target.keys())) + raise ValueError( + f"Each RCON target must define a non-empty '{field_name}'. " + f"Available fields: {available_fields or 'none'}." + ) + return value + + +def _required_positive_int(raw_target: Mapping[str, object], field_name: str) -> int: + raw_value = raw_target.get(field_name) + try: + value = int(raw_value) + except (TypeError, ValueError) as error: + available_fields = ", ".join(sorted(raw_target.keys())) + raise ValueError( + f"Each RCON target must define a valid integer '{field_name}'. " + f"Available fields: {available_fields or 'none'}." + ) from error + if value <= 0: + raise ValueError(f"Each RCON target must define a positive '{field_name}'.") + return value + + +def _slug_to_display_name(slug: str | None) -> str | None: + normalized_slug = _string_or_none(slug) + if normalized_slug is None: + return None + if normalized_slug.startswith("comunidad-hispana-"): + suffix = normalized_slug.removeprefix("comunidad-hispana-") + if suffix.isdigit(): + return f"Comunidad Hispana #{suffix.zfill(2)}" + parts = [part for part in normalized_slug.replace("_", "-").split("-") if part] + if not parts: + return None + return " ".join(part.upper() if part.isdigit() else part.capitalize() for part in parts) + + +def _classify_socket_error_type(error: OSError) -> str: + if isinstance(error, TimeoutError): + return "timeout" + if isinstance(error, ConnectionRefusedError): + return "connection-refused" + if getattr(error, "errno", None) in {10060, 110, 60}: + return "timeout" + return "other-error" + + +def _classify_runtime_error_type(error: RuntimeError) -> str: + message = str(error).lower() + if "auth token" in message or "login failed" in message or "status 401" in message or "status 403" in message: + return "auth/login" + if "invalid json" in message or "unexpected payload" in message or "malformed" in message or "invalid rcon" in message: + return "payload-invalid" + if "timed out" in message: + return "timeout" + return "other-error" diff --git a/backend/app/rcon_historical_worker.py b/backend/app/rcon_historical_worker.py index 999f0f5..d0a90f9 100644 --- a/backend/app/rcon_historical_worker.py +++ b/backend/app/rcon_historical_worker.py @@ -12,8 +12,14 @@ from .config import ( get_rcon_historical_capture_interval_seconds, get_rcon_historical_capture_max_retries, get_rcon_historical_capture_retry_delay_seconds, + get_rcon_request_timeout_seconds, +) +from .rcon_client import ( + RconQueryError, + build_rcon_target_key, + load_rcon_targets, + query_live_server_sample, ) -from .rcon_client import build_rcon_target_key, load_rcon_targets, query_live_server_sample from .rcon_historical_storage import ( finalize_rcon_historical_capture_run, initialize_rcon_historical_storage, @@ -60,13 +66,17 @@ def run_rcon_historical_capture_unlocked( stats = RconHistoricalCaptureStats() items: list[dict[str, object]] = [] errors: list[dict[str, object]] = [] + timeout_seconds = get_rcon_request_timeout_seconds() try: for target in selected_targets: target_metadata = _serialize_target(target) stats.targets_seen += 1 try: - sample = query_live_server_sample(target) + sample = query_live_server_sample( + target, + timeout_seconds=timeout_seconds, + ) delta = persist_rcon_historical_sample( run_id=run_id, captured_at=captured_at, @@ -80,6 +90,10 @@ def run_rcon_historical_capture_unlocked( { "target_key": target_metadata["target_key"], "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "timeout_seconds": timeout_seconds, "captured_at": captured_at, "sample_inserted": bool(delta["samples_inserted"]), "normalized": sample["normalized"], @@ -90,17 +104,9 @@ def run_rcon_historical_capture_unlocked( mark_rcon_historical_capture_failure( run_id=run_id, target=target_metadata, - error_message=str(exc), - ) - errors.append( - { - "target_key": target_metadata["target_key"], - "name": target.name, - "host": target.host, - "port": target.port, - "message": str(exc), - } + error_message=_format_error_message(exc), ) + errors.append(_serialize_capture_error(target, exc, timeout_seconds=timeout_seconds)) status = "success" if not errors else ("partial" if items else "failed") finalize_rcon_historical_capture_run( @@ -239,6 +245,44 @@ def _serialize_target(target: object) -> dict[str, object]: } +def _serialize_capture_error( + target: object, + error: Exception, + *, + timeout_seconds: float, +) -> dict[str, object]: + error_type = _classify_capture_error_type(error) + return { + "target_key": build_rcon_target_key(target), + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "timeout_seconds": timeout_seconds, + "error_type": error_type, + "message": str(error), + } + + +def _classify_capture_error_type(error: Exception) -> str: + if isinstance(error, RconQueryError): + return error.error_type + message = str(error).lower() + if "timed out" in message or "timeout" in message: + return "timeout" + if "401" in message or "403" in message or "login" in message or "auth" in message: + return "auth/login" + if "refused" in message: + return "connection-refused" + if "payload" in message or "json" in message or "malformed" in message: + return "payload-invalid" + return "other-error" + + +def _format_error_message(error: Exception) -> str: + return f"[{_classify_capture_error_type(error)}] {error}" + + def build_arg_parser() -> argparse.ArgumentParser: """Create the CLI parser for manual or periodic prospective RCON capture.""" parser = argparse.ArgumentParser( diff --git a/docker-compose.yml b/docker-compose.yml index 39ca0c4..49511ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,9 @@ services: environment: HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} - HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10} - HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: >- + ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #03","slug":"comunidad-hispana-03","external_server_id":"comunidad-hispana-03","host":"5.196.78.45","port":27230,"password":"replace-me-03","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} ports: - "8000:8000" volumes: @@ -26,8 +27,9 @@ services: environment: HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} - HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10} - HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: >- + ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #03","slug":"comunidad-hispana-03","external_server_id":"comunidad-hispana-03","host":"5.196.78.45","port":27230,"password":"replace-me-03","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} depends_on: - backend volumes: @@ -44,8 +46,9 @@ services: environment: HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} - HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10} - HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: >- + ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #03","slug":"comunidad-hispana-03","external_server_id":"comunidad-hispana-03","host":"5.196.78.45","port":27230,"password":"replace-me-03","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600} HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2} HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15}