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
This commit is contained in:
Ejiro Asiuwhu
2026-03-24 22:23:56 +01:00
parent 7b75ba81a8
commit 28171d43cf
@@ -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()