from __future__ import annotations import asyncio import logging from dataclasses import dataclass, field import aiohttp from yuxi.channels.models import HealthStatus from yuxi.channels.adapters.signal.client import RpcClient, RpcError logger = logging.getLogger(__name__) PROBE_TIMEOUT_MS = 7500 class ProbeError(Exception): def __init__(self, message: str, error_type: str = "UNKNOWN"): super().__init__(message) self.error_type = error_type PROBE_ERROR_UNAUTHORIZED = "UNAUTHORIZED" PROBE_ERROR_NOT_FOUND = "NOT_FOUND" PROBE_ERROR_DAEMON_UNREACHABLE = "SIGNAL_DAEMON_UNREACHABLE" PROBE_ERROR_TIMEOUT = "TIMEOUT" PROBE_ERROR_UNKNOWN = "UNKNOWN" @dataclass class SignalProbeResult: status: str = "unknown" version: str | None = None error_type: str | None = None latency_ms: float = 0.0 metadata: dict = field(default_factory=dict) @property def success(self) -> bool: return self.status == "healthy" and self.version is not None async def probe_signal_daemon(rpc_client: RpcClient) -> SignalProbeResult: import time as _time start = _time.monotonic() base_url = rpc_client.base_url try: about_result = await _probe_about_endpoint(base_url) if about_result: elapsed = (_time.monotonic() - start) * 1000 return SignalProbeResult( status="healthy", version=about_result.get("version", "unknown"), latency_ms=elapsed, metadata={"arm": "about", "about": about_result}, ) except Exception: logger.debug("About endpoint probe failed, falling back to version RPC") try: version_result = await asyncio.wait_for( rpc_client.call("version"), timeout=PROBE_TIMEOUT_MS / 1000.0, ) elapsed = (_time.monotonic() - start) * 1000 return SignalProbeResult( status="healthy", version=version_result.get("version", "unknown"), latency_ms=elapsed, metadata={"arm": "rpc_version"}, ) except TimeoutError: return SignalProbeResult( status="unhealthy", error_type=PROBE_ERROR_TIMEOUT, latency_ms=PROBE_TIMEOUT_MS, ) except RpcError as e: return SignalProbeResult( status="unhealthy", error_type=_classify_rpc_error(e), metadata={"error": str(e)}, ) except Exception as e: return SignalProbeResult( status="unhealthy", error_type=PROBE_ERROR_DAEMON_UNREACHABLE, metadata={"error": str(e)}, ) async def _probe_about_endpoint(base_url: str) -> dict | None: url = f"{base_url}/api/v1/about" try: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status == 200: return await resp.json() except Exception: pass return None async def health_check_signal(rpc_client: RpcClient) -> HealthStatus: try: probe_result = await probe_signal_daemon(rpc_client) if not probe_result.success: return HealthStatus( status="unhealthy", last_error=probe_result.error_type or "unknown error", metadata=probe_result.metadata, ) return HealthStatus( status="healthy", metadata={ "version": probe_result.version or "unknown", "latency_ms": probe_result.latency_ms, **probe_result.metadata, }, ) except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) def _classify_rpc_error(error: RpcError) -> str: msg = str(error).lower() if error.code == -32602: return PROBE_ERROR_NOT_FOUND if "unauthorized" in msg or "authorization" in msg or "forbidden" in msg: return PROBE_ERROR_UNAUTHORIZED if "not found" in msg or "missing" in msg: return PROBE_ERROR_NOT_FOUND if "refused" in msg or "unreachable" in msg or "connect" in msg: return PROBE_ERROR_DAEMON_UNREACHABLE return PROBE_ERROR_UNKNOWN