mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
- Add publishable_client_key param to BaseAPIClient, StackServerApp, AsyncStackServerApp - Include x-stack-publishable-client-key header when key is provided - Change ExplicitTokenStore defaults from empty string to None - Remove empty string fallback in resolve_token_store dict branch
285 lines
9.9 KiB
Python
285 lines
9.9 KiB
Python
"""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,
|
|
publishable_client_key: str | None = None,
|
|
) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
self._project_id = project_id
|
|
self._secret_server_key = secret_server_key
|
|
self._publishable_client_key = publishable_client_key
|
|
self._client: HttpxClientT | None = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Header / URL helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_headers(self) -> dict[str, str]:
|
|
headers = {
|
|
"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()),
|
|
}
|
|
if self._publishable_client_key is not None:
|
|
headers["x-stack-publishable-client-key"] = self._publishable_client_key
|
|
return headers
|
|
|
|
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()
|