"""Shared HTTP transport and helpers for sync and async Conduit clients.""" from __future__ import annotations import asyncio import time from typing import Any from urllib.parse import quote import httpx from pydantic import TypeAdapter from .exceptions import ( AuthenticationError, ConduitAPIError, ConflictError, NotFoundError, RateLimitError, ValidationError, ) _IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) _RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504}) class _BaseClientMixin: """Helpers shared by sync and async transports.""" api_key: str base_url: str timeout: float max_retries: int def _auth_header(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.api_key}"} def _build_url(self, path: str) -> str: return f"{self.base_url}/{path.lstrip('/')}" @staticmethod def _encode_path_param(value: str) -> str: return quote(value, safe="") def _should_retry(self, method: str, status_code: int, attempt: int) -> bool: return ( self.max_retries > 0 and attempt < self.max_retries and method in _IDEMPOTENT_METHODS and status_code in _RETRYABLE_STATUS_CODES ) def _handle_response(self, response: httpx.Response, response_model: Any | None = None) -> Any: if response.status_code >= 400: self._raise_for_status(response) if response.status_code == 204: return None content_type = response.headers.get("content-type", "") looks_like_json = ( "application/json" in content_type or response.text.lstrip().startswith(("{", "[")) ) if looks_like_json: data = response.json() else: return response.text if response_model is None: return data return TypeAdapter(response_model).validate_python(data) @staticmethod def _raise_for_status(response: httpx.Response) -> None: try: body = response.json() except Exception: body = response.text message = body if isinstance(body, str) else str(body) status_code = response.status_code if status_code in (401, 403): raise AuthenticationError(message, status_code=status_code, response_body=body) if status_code == 404: raise NotFoundError(message, status_code=status_code, response_body=body) if status_code == 422: raise ValidationError(message, status_code=status_code, response_body=body) if status_code == 409: raise ConflictError(message, status_code=status_code, response_body=body) if status_code == 429: raise RateLimitError(message, status_code=status_code, response_body=body) raise ConduitAPIError(message, status_code=status_code, response_body=body) class SyncTransport(_BaseClientMixin): """Synchronous HTTP transport backed by httpx.Client.""" def __init__( self, api_key: str, base_url: str = "https://api.cowtunnel.com", timeout: float = 30.0, max_retries: int = 0, **httpx_kwargs: Any, ) -> None: self.api_key = api_key self.base_url = base_url.rstrip("/") self.timeout = timeout self.max_retries = max_retries self._client = httpx.Client( base_url=self.base_url, timeout=self.timeout, headers=self._auth_header(), **httpx_kwargs, ) def request( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any = None, headers: dict[str, str] | None = None, response_model: Any | None = None, ) -> Any: url = self._build_url(path) request_headers = {**(headers or {}), **self._auth_header()} last_exception: Exception | None = None for attempt in range(self.max_retries + 1): response = self._client.request( method, url, params=params, json=json, headers=request_headers, timeout=self.timeout, ) if response.status_code < 400 or not self._should_retry( method, response.status_code, attempt ): return self._handle_response(response, response_model) last_exception = self._make_exception(response) if attempt < self.max_retries: time.sleep(2**attempt * 0.5) if last_exception is not None: raise last_exception return None # pragma: no cover def close(self) -> None: self._client.close() @staticmethod def _make_exception(response: httpx.Response) -> ConduitAPIError: try: body = response.json() except Exception: body = response.text message = body if isinstance(body, str) else str(body) return ConduitAPIError(message, status_code=response.status_code, response_body=body) class AsyncTransport(_BaseClientMixin): """Asynchronous HTTP transport backed by httpx.AsyncClient.""" def __init__( self, api_key: str, base_url: str = "https://api.cowtunnel.com", timeout: float = 30.0, max_retries: int = 0, **httpx_kwargs: Any, ) -> None: self.api_key = api_key self.base_url = base_url.rstrip("/") self.timeout = timeout self.max_retries = max_retries self._client = httpx.AsyncClient( base_url=self.base_url, timeout=self.timeout, headers=self._auth_header(), **httpx_kwargs, ) async def request( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any = None, headers: dict[str, str] | None = None, response_model: Any | None = None, ) -> Any: url = self._build_url(path) request_headers = {**(headers or {}), **self._auth_header()} last_exception: Exception | None = None for attempt in range(self.max_retries + 1): response = await self._client.request( method, url, params=params, json=json, headers=request_headers, timeout=self.timeout, ) if response.status_code < 400 or not self._should_retry( method, response.status_code, attempt ): return self._handle_response(response, response_model) last_exception = self._make_exception(response) if attempt < self.max_retries: await asyncio.sleep(2**attempt * 0.5) if last_exception is not None: raise last_exception return None # pragma: no cover async def aclose(self) -> None: await self._client.aclose() @staticmethod def _make_exception(response: httpx.Response) -> ConduitAPIError: try: body = response.json() except Exception: body = response.text message = body if isinstance(body, str) else str(body) return ConduitAPIError(message, status_code=response.status_code, response_body=body)