Initial commit: add Gitea PyPI publish workflow
This commit is contained in:
224
src/conduit_client/_base_client.py
Normal file
224
src/conduit_client/_base_client.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user