import logging from functools import cached_property from types import TracebackType from typing import Any, Self, overload import httpx from httpx._client import USE_CLIENT_DEFAULT, UseClientDefault # noqa from httpx._types import ( # noqa AuthTypes, CookieTypes, HeaderTypes, QueryParamTypes, RequestContent, RequestData, RequestExtensions, RequestFiles, TimeoutTypes, URLTypes, ) from pydantic import TypeAdapter, ValidationError from .exceptions import HTTPClientError logger = logging.getLogger(__name__) class BaseHTTPClient[ClientT: httpx.Client | httpx.AsyncClient]: exception_class: type[HTTPClientError] = HTTPClientError sensitive_params: set[str] = set() def __init__( self, client_options: dict[str, Any] | None = None, ) -> None: self._client: ClientT | None = None self._client_options = client_options or {} @property def client(self) -> ClientT: if self._client is None: raise AttributeError("HTTP client is not started.") return self._client @cached_property def name(self) -> str: return self.__class__.__name__ def sanitize_url(self, url: httpx.URL) -> httpx.URL: for key, _ in url.params.items(): if key in self.sensitive_params: url = url.copy_remove_param(key) return url def handle_response[T]( self, response: httpx.Response, type: type[T] | None = None ) -> httpx.Response | T: # Sanitize request url for exception stacktrace context if self.sensitive_params: response.request.url = self.sanitize_url(response.url) # Check response status try: response.raise_for_status() except httpx.HTTPStatusError as exc: raise self.exception_class( f"Invalid {self.name} API response status: {exc.response.status_code}", request=exc.request, response=exc.response, ) from exc # Deserialize response if type is not None: try: return TypeAdapter(type).validate_json(response.content) except ValidationError as exc: raise self.exception_class( ( f"Failed to deserialize {self.name} API response " f"into type {type.__name__}" ), request=response.request, response=response, ) from exc return response class HTTPClient(BaseHTTPClient[httpx.Client]): """HTTP adapter.""" def start(self) -> None: """Start HTTP client.""" if self._client is None: self._client = httpx.Client( **self._client_options, ) def close(self) -> None: """Close HTTP client.""" self.client.close() def __enter__(self) -> Self: self.start() return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: self.close() @overload def request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, type: type[T], ) -> T: ... @overload def request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, ) -> httpx.Response: ... def request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, type: type[T] | None = None, ) -> httpx.Response | T: try: return self._request( method, url, content=content, data=data, files=files, json=json, params=params, headers=headers, cookies=cookies, auth=auth, follow_redirects=follow_redirects, timeout=timeout, extensions=extensions, type=type, ) except self.exception_class as exc: logger.exception(exc.log_msg, extra=exc.log_extra) raise exc def _request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, type: type[T] | None = None, ) -> httpx.Response | T: """Make a request to the API.""" # Make request try: response = self.client.request( method, url, content=content, data=data, files=files, json=json, params=params, headers=headers, cookies=cookies, auth=auth, follow_redirects=follow_redirects, timeout=timeout, extensions=extensions, ) except httpx.RequestError as exc: raise self.exception_class( f"Failed to request the {self.name} API.", request=exc.request, ) from exc return self.handle_response(response, type=type) class AsyncHTTPClient(BaseHTTPClient[httpx.AsyncClient]): """HTTP adapter.""" async def start(self) -> None: """Start HTTP client.""" if self._client is None: self._client = httpx.AsyncClient(**self._client_options) async def close(self) -> None: """Close HTTP client.""" await self.client.aclose() async def __aenter__(self) -> Self: await self.start() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: await self.close() @overload async def request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, type: type[T], ) -> T: ... @overload async def request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, ) -> httpx.Response: ... async def request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, type: type[T] | None = None, ) -> httpx.Response | T: try: return await self._request( method, url, content=content, data=data, files=files, json=json, params=params, headers=headers, cookies=cookies, auth=auth, follow_redirects=follow_redirects, timeout=timeout, extensions=extensions, type=type, ) except self.exception_class as exc: logger.exception(exc.log_msg, extra=exc.log_extra) raise exc async def _request[T]( self, method: str, url: URLTypes, *, content: RequestContent | None = None, data: RequestData | None = None, files: RequestFiles | None = None, json: Any | None = None, params: QueryParamTypes | None = None, headers: HeaderTypes | None = None, cookies: CookieTypes | None = None, auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, extensions: RequestExtensions | None = None, type: type[T] | None = None, ) -> httpx.Response | T: """Make a request to the API.""" try: response = await self.client.request( method, url, content=content, data=data, files=files, json=json, params=params, headers=headers, cookies=cookies, auth=auth, follow_redirects=follow_redirects, timeout=timeout, extensions=extensions, ) except httpx.RequestError as exc: raise self.exception_class( f"Failed to request the {self.name} API.", request=exc.request, ) from exc return self.handle_response(response, type=type)