"""Base httpx async client for Stock Oracle.""" from __future__ import annotations from typing import Any import httpx from libs.common.retries import with_retry from libs.oracle_client.exceptions import ( OracleClientError, OracleConnectionError, OracleNotFoundError, OracleServerError, OracleTimeoutError, OracleTransportError, ) class OracleClient: """Async HTTP client wrapping Stock Oracle REST API.""" _HEALTH_PATH = "/api/v1/health" @staticmethod def _is_healthy_payload(data: Any) -> bool: if not isinstance(data, dict): return False status = str(data.get("status") or "").strip().lower() return status in {"ok", "healthy"} def __init__(self, base_url: str, timeout: float = 30.0) -> None: self._base_url = base_url.rstrip("/") self._timeout = timeout self._client: httpx.AsyncClient | None = None async def __aenter__(self) -> OracleClient: self._client = httpx.AsyncClient( base_url=self._base_url, timeout=self._timeout, ) return self async def __aexit__(self, *args: Any) -> None: if self._client: await self._client.aclose() self._client = None def _ensure_client(self) -> httpx.AsyncClient: if self._client is None: raise RuntimeError("OracleClient must be used as async context manager.") return self._client @with_retry(max_attempts=3, min_wait=0.1, max_wait=5.0, multiplier=0.1) async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: client = self._ensure_client() try: response = await client.get(path, params=params) except httpx.ConnectError as exc: raise OracleConnectionError(str(exc), source="oracle", entity=path) from exc except httpx.TimeoutException as exc: raise OracleTimeoutError(str(exc), source="oracle", entity=path) from exc except httpx.TransportError as exc: raise OracleTransportError(str(exc), source="oracle", entity=path) from exc return self._handle_response(response, path) @with_retry(max_attempts=3, min_wait=0.1, max_wait=5.0, multiplier=0.1) async def post( self, path: str, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> Any: client = self._ensure_client() try: response = await client.post(path, json=json, params=params) except httpx.ConnectError as exc: raise OracleConnectionError(str(exc), source="oracle", entity=path) from exc except httpx.TimeoutException as exc: raise OracleTimeoutError(str(exc), source="oracle", entity=path) from exc except httpx.TransportError as exc: raise OracleTransportError(str(exc), source="oracle", entity=path) from exc return self._handle_response(response, path) def _handle_response(self, response: httpx.Response, path: str) -> Any: if response.status_code == 404: raise OracleNotFoundError( f"Not found: {path}", source="oracle", entity=path, context={"status_code": 404}, ) if response.status_code >= 500: raise OracleServerError( f"Server error {response.status_code}: {path}", source="oracle", entity=path, context={"status_code": response.status_code}, ) if response.status_code >= 400: raise OracleClientError( f"Client error {response.status_code}: {path}", source="oracle", entity=path, context={"status_code": response.status_code}, ) try: return response.json() except (ValueError, Exception) as exc: raise OracleTransportError( f"Malformed JSON from {path}: {exc}", source="oracle", entity=path, ) from exc async def health_check(self) -> bool: try: data = await self.get(self._HEALTH_PATH) return self._is_healthy_payload(data) except Exception: return False async def health_check_fast(self, timeout: float = 3.0) -> bool: """Cheap one-shot health probe without retry backoff. This is intended for research/cache-heavy workflows that can proceed in a degraded mode when Oracle is temporarily unavailable. It avoids waiting on the main client's longer timeout and retry policy just to decide whether uncached misses should be skipped. """ try: async with httpx.AsyncClient( base_url=self._base_url, timeout=timeout, ) as client: response = await client.get(self._HEALTH_PATH) data = response.json() return response.status_code < 400 and self._is_healthy_payload(data) except Exception: return False def make_oracle_client() -> OracleClient: from libs.common.config import get_settings s = get_settings() return OracleClient(base_url=s.stock_oracle_url, timeout=float(s.stock_oracle_timeout))