From 28171d43cfc5f185bc50124373b4d5b1acb4d2eb Mon Sep 17 00:00:00 2001 From: Ejiro Asiuwhu Date: Tue, 24 Mar 2026 22:23:56 +0100 Subject: [PATCH] feat: add sync and async HTTP client with retry logic - BaseAPIClient generic base with header construction, URL building, response parsing via x-stack-actual-status and x-stack-known-error - SyncAPIClient wrapping httpx.Client with context manager support - AsyncAPIClient wrapping httpx.AsyncClient with async context manager - Exponential backoff retry for idempotent methods (GET/PUT/DELETE) - 429 rate limit handling with Retry-After header support - POST/PUT/PATCH with no body sends {} as JSON - All 26 tests passing --- .../python/src/stack_auth/_client.py | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 sdks/implementations/python/src/stack_auth/_client.py diff --git a/sdks/implementations/python/src/stack_auth/_client.py b/sdks/implementations/python/src/stack_auth/_client.py new file mode 100644 index 000000000..f51d85ecd --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/_client.py @@ -0,0 +1,279 @@ +"""Sync and async HTTP clients for the Stack Auth API. + +Provides BaseAPIClient[T], SyncAPIClient, and AsyncAPIClient implementing the +full request pipeline: header construction, URL building, response processing +with x-stack-actual-status, error dispatch via x-stack-known-error, retry with +exponential backoff for idempotent methods, and rate limit handling. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any, Generic, TypeVar + +import httpx + +from stack_auth._constants import API_VERSION, DEFAULT_BASE_URL, SDK_NAME +from stack_auth._version import __version__ +from stack_auth.errors import StackAuthError + +HttpxClientT = TypeVar("HttpxClientT", httpx.Client, httpx.AsyncClient) + + +class BaseAPIClient(Generic[HttpxClientT]): + """Generic base class shared by sync and async clients. + + Handles header construction, URL building, response parsing, and retry + policy. Subclasses provide the concrete httpx transport. + """ + + IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) + MAX_RETRIES = 5 + + def __init__( + self, + *, + base_url: str = DEFAULT_BASE_URL, + project_id: str, + secret_server_key: str, + ) -> None: + self._base_url = base_url.rstrip("/") + self._project_id = project_id + self._secret_server_key = secret_server_key + self._client: HttpxClientT | None = None + + # ------------------------------------------------------------------ + # Header / URL helpers + # ------------------------------------------------------------------ + + def _build_headers(self) -> dict[str, str]: + return { + "x-stack-project-id": self._project_id, + "x-stack-access-type": "server", + "x-stack-secret-server-key": self._secret_server_key, + "x-stack-client-version": f"{SDK_NAME}@{__version__}", + "x-stack-override-error-status": "true", + "x-stack-random-nonce": str(uuid.uuid4()), + } + + def _build_url(self, path: str) -> str: + return f"{self._base_url}/api/{API_VERSION}{path}" + + # ------------------------------------------------------------------ + # Response processing + # ------------------------------------------------------------------ + + def _parse_response(self, response: httpx.Response) -> tuple[int, dict[str, Any] | None]: + """Parse an httpx response according to the Stack Auth protocol. + + Returns ``(actual_status, parsed_json)`` on success. + Raises the appropriate :class:`StackAuthError` subclass on failure. + """ + # Determine real status + actual_status_header = response.headers.get("x-stack-actual-status") + actual_status = int(actual_status_header) if actual_status_header else response.status_code + + # Known-error dispatch + known_error = response.headers.get("x-stack-known-error") + if known_error: + try: + body = response.json() + except Exception: + body = {} + raise StackAuthError.from_response( + code=known_error, + message=body.get("message", "Unknown error"), + details=body.get("details"), + ) + + # Success range + if 200 <= actual_status < 300: + if response.content: + return actual_status, response.json() + return actual_status, None + + # Unrecognised error + raise StackAuthError(code="HTTP_ERROR", message=f"HTTP {actual_status}") + + # ------------------------------------------------------------------ + # Retry helpers + # ------------------------------------------------------------------ + + def _should_retry(self, method: str, attempt: int) -> bool: + return method.upper() in self.IDEMPOTENT_METHODS and attempt < self.MAX_RETRIES + + @staticmethod + def _get_retry_delay(attempt: int, response: httpx.Response | None = None) -> float: + if response is not None and response.status_code == 429: + retry_after = response.headers.get("Retry-After") + if retry_after is not None: + try: + return float(retry_after) + except ValueError: + pass + # Check x-stack-actual-status for 429 too + if response is not None: + actual = response.headers.get("x-stack-actual-status") + if actual == "429": + retry_after = response.headers.get("Retry-After") + if retry_after is not None: + try: + return float(retry_after) + except ValueError: + pass + return 1.0 * (2 ** attempt) + + +class SyncAPIClient(BaseAPIClient[httpx.Client]): + """Synchronous HTTP client using :class:`httpx.Client`.""" + + def _get_client(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=httpx.Timeout(30.0)) + return self._client + + def request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + url = self._build_url(path) + headers = self._build_headers() + + # POST/PUT/PATCH: always send a JSON body (default to {} if None) + method_upper = method.upper() + if method_upper in {"POST", "PUT", "PATCH"}: + json_body = body if body is not None else {} + else: + json_body = body # may be None → no body + + last_exc: BaseException | None = None + for attempt in range(self.MAX_RETRIES + 1): + try: + resp = self._get_client().request( + method_upper, + url, + headers=headers, + json=json_body, + params=params, + ) + + # Check for 429 via x-stack-actual-status + actual_status_hdr = resp.headers.get("x-stack-actual-status") + actual_status = int(actual_status_hdr) if actual_status_hdr else resp.status_code + + if actual_status == 429 and attempt < self.MAX_RETRIES: + delay = self._get_retry_delay(attempt, resp) + time.sleep(delay) + continue + + _status, data = self._parse_response(resp) + return data + + except (httpx.HTTPError, httpx.TimeoutException) as exc: + last_exc = exc + if self._should_retry(method_upper, attempt): + delay = self._get_retry_delay(attempt, None) + time.sleep(delay) + continue + raise + + # Exhausted retries + if last_exc is not None: + raise last_exc + return None # pragma: no cover + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + if self._client is not None: + self._client.close() + self._client = None + + def __enter__(self) -> SyncAPIClient: + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + +class AsyncAPIClient(BaseAPIClient[httpx.AsyncClient]): + """Asynchronous HTTP client using :class:`httpx.AsyncClient`.""" + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) + return self._client + + async def request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + url = self._build_url(path) + headers = self._build_headers() + + method_upper = method.upper() + if method_upper in {"POST", "PUT", "PATCH"}: + json_body = body if body is not None else {} + else: + json_body = body + + last_exc: BaseException | None = None + for attempt in range(self.MAX_RETRIES + 1): + try: + resp = await self._get_client().request( + method_upper, + url, + headers=headers, + json=json_body, + params=params, + ) + + actual_status_hdr = resp.headers.get("x-stack-actual-status") + actual_status = int(actual_status_hdr) if actual_status_hdr else resp.status_code + + if actual_status == 429 and attempt < self.MAX_RETRIES: + delay = self._get_retry_delay(attempt, resp) + await asyncio.sleep(delay) + continue + + _status, data = self._parse_response(resp) + return data + + except (httpx.HTTPError, httpx.TimeoutException) as exc: + last_exc = exc + if self._should_retry(method_upper, attempt): + delay = self._get_retry_delay(attempt, None) + await asyncio.sleep(delay) + continue + raise + + if last_exc is not None: + raise last_exc + return None # pragma: no cover + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> AsyncAPIClient: + return self + + async def __aexit__(self, *_: Any) -> None: + await self.aclose()