You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

138 lines
4.7 KiB
Python

"""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,
)
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
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
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},
)
return response.json()
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))