"""Async HTTP client for the Fable Assistant API.""" from __future__ import annotations import os from typing import Any, AsyncIterator import httpx class FableAPIError(Exception): """Raised when the Fable API returns a non-2xx response.""" def __init__(self, status_code: int, message: str) -> None: self.status_code = status_code super().__init__(f"Fable API error {status_code}: {message}") class FableClient: """Async wrapper around httpx for the Fable REST API.""" def __init__(self) -> None: url = os.environ.get("FABLE_URL", "").rstrip("/") key = os.environ.get("FABLE_API_KEY", "") if not url: raise ValueError("FABLE_URL environment variable is required") if not key: raise ValueError("FABLE_API_KEY environment variable is required") self.base_url = url self._headers = { "Authorization": f"Bearer {key}", "Content-Type": "application/json", } self._client: httpx.AsyncClient | None = None async def __aenter__(self) -> "FableClient": self._client = httpx.AsyncClient( base_url=self.base_url, headers=self._headers, timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0), ) return self async def __aexit__(self, *args: Any) -> None: if self._client: await self._client.aclose() self._client = None def _http(self) -> httpx.AsyncClient: if self._client is None: raise RuntimeError("FableClient must be used as an async context manager") return self._client @staticmethod def _raise_for_status(response: httpx.Response) -> None: if response.is_error: try: message = response.json().get("error", response.text) except Exception: message = response.text raise FableAPIError(response.status_code, message) async def get(self, path: str, **kwargs: Any) -> Any: response = await self._http().get(path, **kwargs) self._raise_for_status(response) return response.json() async def post(self, path: str, **kwargs: Any) -> Any: response = await self._http().post(path, **kwargs) self._raise_for_status(response) return response.json() async def patch(self, path: str, **kwargs: Any) -> Any: response = await self._http().patch(path, **kwargs) self._raise_for_status(response) return response.json() async def put(self, path: str, **kwargs: Any) -> Any: response = await self._http().put(path, **kwargs) self._raise_for_status(response) return response.json() async def delete(self, path: str, **kwargs: Any) -> Any: response = await self._http().delete(path, **kwargs) if response.status_code == 204: return None self._raise_for_status(response) if response.content: return response.json() return None async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]: """Yield non-empty lines from a streaming GET response (SSE).""" async with self._http().stream("GET", path, **kwargs) as response: if response.is_error: await response.aread() self._raise_for_status(response) async for line in response.aiter_lines(): if line: yield line # --------------------------------------------------------------------------- # Module-level singleton # --------------------------------------------------------------------------- _client: FableClient | None = None def init_client() -> FableClient: """Initialise the module-level FableClient singleton (reads env vars).""" global _client _client = FableClient() return _client def get_client() -> FableClient: """Return the singleton client; raises RuntimeError if not initialised.""" if _client is None: raise RuntimeError("Call init_client() before get_client()") return _client def _reset_client() -> None: """Reset the singleton — used by tests only.""" global _client _client = None