From da5bab4378c0119311e9604e64f6cbe956ffa72a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 16 Sep 2026 11:20:08 -0400 Subject: [PATCH 1/3] feat: switch default HTTP client to httpx2 and accept a custom http_client httpx has had no stable release since 0.28.1 (Dec 2024). httpx2 is the Pydantic-stewarded fork of that release with an identical API, so the SDK now depends on httpx2 instead of httpx. The HTTP layer moves behind two small protocols, workos.HTTPBackend and workos.AsyncHTTPBackend, in the new hand-maintained src/workos/_http.py. WorkOSClient, AsyncWorkOSClient and create_public_client take an http_client= argument that accepts an httpx2 client, an httpx 0.28 client, or any object implementing the protocol. The SDK closes only clients it created itself. Query and JSON body encoding now happen in the base client so every backend sends identical bytes. Tests replace pytest-httpx, which pins httpx==0.28.* and cannot intercept httpx2, with a hand-maintained httpx_mock fixture in tests/conftest.py that preserves the API the oagen-generated test files rely on. httpx is no longer a transitive dependency of workos; projects that import it directly must declare it themselves. Refs #730 --- README.md | 75 ++++++ pyproject.toml | 6 +- src/workos/__init__.py | 14 + src/workos/_base_client.py | 158 +++++++++--- src/workos/_http.py | 315 +++++++++++++++++++++++ src/workos/public_client.py | 4 + tests/conftest.py | 89 +++++++ tests/smoke_test.py | 4 +- tests/test_generated_client.py | 2 +- tests/test_http_backends.py | 453 +++++++++++++++++++++++++++++++++ uv.lock | 89 +++++-- 11 files changed, 1141 insertions(+), 68 deletions(-) create mode 100644 src/workos/_http.py create mode 100644 tests/test_http_backends.py diff --git a/README.md b/README.md index 633f9bf1..0d7da15a 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,81 @@ except RateLimitExceededError as e: The client automatically retries requests up to 3 times (configurable via the `max_retries` request option) on 429 and 5xx responses, timeouts, and connection errors, using exponential backoff with jitter and honoring `Retry-After`. The SDK attaches an auto-generated `Idempotency-Key` (UUID v4) to every `POST` request and reuses the same key across its internal retries. +## HTTP Backends + +The SDK sends requests through [`httpx2`](https://pypi.org/project/httpx2/) by default. Pass your own configured client as `http_client` to control proxies, TLS, connection limits, or transports: + +```python +import httpx2 +from workos import AsyncWorkOSClient, WorkOSClient + +client = WorkOSClient( + api_key="sk_...", + http_client=httpx2.Client(proxy="http://proxy.internal:3128", verify="/etc/ssl/corp.pem"), +) + +async_client = AsyncWorkOSClient( + api_key="sk_...", + http_client=httpx2.AsyncClient(limits=httpx2.Limits(max_connections=20)), +) +``` + +`httpx` 0.28 clients are accepted as well; the two libraries share an API. The SDK closes only clients it created. A client you pass in stays open after `client.close()`, so close it yourself when you are done with it. + +### Custom backends + +Any object implementing `workos.HTTPBackend` (or `workos.AsyncHTTPBackend` for the async client) can be passed as `http_client`. The SDK hands the backend a fully built URL, the final headers, an optional `bytes` body, and a timeout in seconds, and expects a `workos.HTTPResponse` back. Raise `workos.TransportTimeout`, `workos.TransportConnectError`, or `workos.TransportError` for network failures so the SDK's retry logic can handle them. The `headers` mapping on the response must be case-insensitive. + +The following `aiohttp` adapter is an example, not a supported part of the SDK: + +```python +import asyncio + +import aiohttp +import yarl + +from workos import ( + AsyncWorkOSClient, + HTTPResponse, + TransportConnectError, + TransportError, + TransportTimeout, +) + + +class AiohttpBackend: + def __init__(self, session: aiohttp.ClientSession) -> None: + self._session = session + + async def request(self, method, url, *, headers, content, timeout) -> HTTPResponse: + try: + async with self._session.request( + method, + yarl.URL(url, encoded=True), # keep the SDK's percent-encoding intact + headers=headers, + data=content, + timeout=aiohttp.ClientTimeout(total=timeout), + allow_redirects=True, + ) as resp: + body = await resp.read() + return HTTPResponse(resp.status, resp.headers, body, method, str(resp.url)) + except (asyncio.TimeoutError, aiohttp.ServerTimeoutError) as exc: # before connection errors + raise TransportTimeout(str(exc)) from exc + except aiohttp.ClientConnectionError as exc: + raise TransportConnectError(str(exc)) from exc + except aiohttp.ClientError as exc: + raise TransportError(str(exc)) from exc + + async def close(self) -> None: + await self._session.close() + + +async def main() -> None: + async with aiohttp.ClientSession() as session: + client = AsyncWorkOSClient(api_key="sk_...", http_client=AiohttpBackend(session)) + page = await client.organizations.list_organizations() +``` + ## Per-Request Options Every API method accepts `request_options` for per-call overrides (local helpers such as webhook/Actions signature verification and PKCE utilities do not make HTTP calls and don't take `request_options`): diff --git a/pyproject.toml b/pyproject.toml index 5f4f3439..6eb4608b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.10" dependencies = [ "cryptography~=50.0", - "httpx~=0.28", + "httpx2~=2.13", "pyjwt~=2.12", "typing_extensions~=4.0; python_version < '3.11'", ] @@ -31,10 +31,10 @@ test = [ "pytest~=9.0", "pytest-asyncio~=1.3", "pytest-cov~=7.1", - "pytest-httpx~=0.36", + "httpx~=0.28", ] lint = ["ruff~=0.15"] -type_check = ["pyright~=1.1"] +type_check = ["pyright~=1.1", "httpx~=0.28"] nox = ["nox~=2026.2", "nox-uv~=0.7"] docs = ["pdoc>=14", "pydoc-markdown>=4", "black~=26.3"] diff --git a/src/workos/__init__.py b/src/workos/__init__.py index faa676ad..1a1182f4 100644 --- a/src/workos/__init__.py +++ b/src/workos/__init__.py @@ -14,6 +14,14 @@ ServerError, UnprocessableEntityError, ) +from ._http import ( + AsyncHTTPBackend, + HTTPBackend, + HTTPResponse, + TransportConnectError, + TransportError, + TransportTimeout, +) from ._pagination import AsyncPage, ListMetadata, SyncPage from .public_client import create_public_client from ._types import NOT_GIVEN, NotGiven, RequestOptions @@ -37,4 +45,10 @@ "NOT_GIVEN", "NotGiven", "create_public_client", + "HTTPBackend", + "AsyncHTTPBackend", + "HTTPResponse", + "TransportError", + "TransportTimeout", + "TransportConnectError", ] diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index bb1f17a3..dc0411ef 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import json import os import platform import time @@ -9,10 +10,8 @@ import random from datetime import datetime, timezone from email.utils import parsedate_to_datetime -from typing import Any, Dict, Optional, Sequence, Type, cast, overload -from urllib.parse import quote - -import httpx +from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Type, cast, overload +from urllib.parse import quote, urlencode from ._errors import ( APIError, @@ -25,9 +24,20 @@ STATUS_CODE_TO_ERROR, _AUTH_CODE_TO_ERROR, ) +from ._http import ( + HTTPResponse, + TransportConnectError, + TransportError, + TransportTimeout, + resolve_async_backend, + resolve_sync_backend, +) from ._pagination import AsyncPage, ListMetadata, SyncPage from ._types import D, Deserializable, RequestOptions +if TYPE_CHECKING: + from ._http import AsyncHTTPClient, SyncHTTPClient + try: from importlib.metadata import version as _pkg_version @@ -96,8 +106,6 @@ def build_url( self, path: Sequence[str], params: Optional[Dict[str, Any]] = None ) -> str: """Build a full URL with query parameters for redirect/authorization endpoints.""" - from urllib.parse import urlencode - base = self._base_url.rstrip("/") url = f"{base}/{self._encode_path(path)}" if params: @@ -153,8 +161,8 @@ def _encode_path(path: Sequence[str]) -> str: Percent-encoding alone does not cover dot segments: ``.`` is an unreserved character that :func:`urllib.parse.quote` leaves as-is, and - httpx applies RFC 3986 dot-segment removal when it builds the request - URL, so a bare ``.`` or ``..`` segment would collapse the path onto the + HTTP clients such as httpx2 apply RFC 3986 dot-segment removal when they + build the request URL, so a bare ``.`` or ``..`` segment would collapse the path onto the parent resource (for example, turning a DELETE of one connected account into a DELETE of the whole user). Empty, ``.`` and ``..`` segments are never valid WorkOS identifiers, so they are rejected with @@ -194,6 +202,57 @@ def _resolve_max_retries(self, request_options: Optional[RequestOptions]) -> int return retries return self._max_retries + @staticmethod + def _query_value(value: Any) -> str: + """Stringify one query value the way httpx does.""" + if value is True: + return "true" + if value is False: + return "false" + if value is None: + return "" + return str(value) + + @staticmethod + def _encode_query(params: Optional[Dict[str, Any]]) -> str: + """Encode query parameters into a query string. + + Mirrors httpx 0.28 so behaviour is identical for every HTTP backend: + booleans become ``true`` / ``false``, ``None`` becomes an empty value, + lists and tuples repeat the key, everything else is ``str()``-ed. + Generated resources rely on this for raw ``bool``, ``int`` and ``list`` + values. + """ + if not params: + return "" + pairs: list[tuple[str, str]] = [] + for key, value in params.items(): + if isinstance(value, (list, tuple)): + for item in cast(Sequence[Any], value): + pairs.append((key, _BaseWorkOSClient._query_value(item))) + else: + pairs.append((key, _BaseWorkOSClient._query_value(value))) + return urlencode(pairs) + + @staticmethod + def _encode_body(body: Optional[Dict[str, Any]]) -> Optional[bytes]: + """Serialize a JSON body in httpx's compact form, or ``None`` for no body.""" + if body is None: + return None + return json.dumps( + body, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + def _build_request_url( + self, + path: Sequence[str], + params: Optional[Dict[str, Any]], + request_options: Optional[RequestOptions], + ) -> str: + url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}" + query = self._encode_query(params) + return f"{url}?{query}" if query else url + def _require_api_key(self) -> str: if not self._api_key: raise ConfigurationError( @@ -238,7 +297,7 @@ def _build_headers( return headers def _deserialize_response( - self, response: httpx.Response, model: Optional[Type[Deserializable]] + self, response: HTTPResponse, model: Optional[Type[Deserializable]] ) -> Any: if response.status_code == 204 or not response.content: return None @@ -251,13 +310,12 @@ def _deserialize_response( return data @staticmethod - def _raise_error(response: httpx.Response) -> None: + def _raise_error(response: HTTPResponse) -> None: """Raise an appropriate error based on the response status code.""" request_id = response.headers.get("x-request-id", "") raw_body = response.text - request = response.request - request_url = str(request.url) if request is not None else None - request_method = request.method if request is not None else None + request_url = response.request_url + request_method = response.request_method response_json: Optional[Dict[str, Any]] = None try: response_json = cast(Dict[str, Any], response.json()) @@ -384,6 +442,7 @@ def __init__( jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, is_public: bool = False, + http_client: Optional[SyncHTTPClient] = None, ) -> None: """Initialize the WorkOS client. @@ -398,9 +457,15 @@ def __init__( / mobile / CLI). The API key is forced to None and the ``WORKOS_API_KEY`` environment variable is ignored. Use ``create_public_client`` instead of setting this directly. + http_client: HTTP client to send requests with. Accepts an + ``httpx2.Client``, an ``httpx.Client``, or any object implementing + :class:`workos.HTTPBackend`. Defaults to a new ``httpx2.Client`` + that this instance owns and closes. A client you pass in is + never closed by the SDK. Raises: ValueError: If neither api_key nor client_id is provided, directly or via environment variables. + TypeError: If ``http_client`` is not a supported client type. """ super().__init__( api_key=api_key, @@ -411,13 +476,17 @@ def __init__( max_retries=max_retries, is_public=is_public, ) - self._client = httpx.Client( - timeout=self._request_timeout, follow_redirects=True + self._backend, self._owns_http_client = resolve_sync_backend( + http_client, self._request_timeout ) def close(self) -> None: - """Close the underlying HTTP client and release resources.""" - self._client.close() + """Release the HTTP client if the SDK created it. + + A client passed in via ``http_client`` stays open; close it yourself. + """ + if self._owns_http_client: + self._backend.close() def __enter__(self) -> "WorkOSClient": return self @@ -463,19 +532,19 @@ def request( request_options: Optional[RequestOptions] = None, ) -> Any: """Make an HTTP request with retry logic.""" - url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}" + url = self._build_request_url(path, params, request_options) headers = self._build_headers(method, idempotency_key, request_options) + content = self._encode_body(body) timeout = self._resolve_timeout(request_options) max_retries = self._resolve_max_retries(request_options) last_error: Optional[Exception] = None for attempt in range(max_retries + 1): try: - response = self._client.request( - method=method.upper(), - url=url, - params=params, - json=body if body is not None else None, + response = self._backend.request( + method.upper(), + url, headers=headers, + content=content, timeout=timeout, ) if response.status_code in RETRY_STATUS_CODES and attempt < max_retries: @@ -487,19 +556,19 @@ def request( if response.status_code >= 400: self._raise_error(response) return self._deserialize_response(response, model) - except httpx.TimeoutException as e: + except TransportTimeout as e: last_error = e if attempt < max_retries: time.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSTimeoutError(f"Request timed out: {e}") from e - except httpx.ConnectError as e: + except TransportConnectError as e: last_error = e if attempt < max_retries: time.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSConnectionError(f"Connection failed: {e}") from e - except httpx.HTTPError as e: + except TransportError as e: last_error = e if attempt < max_retries: time.sleep(self._calculate_retry_delay(attempt)) @@ -615,6 +684,7 @@ def __init__( jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, is_public: bool = False, + http_client: Optional[AsyncHTTPClient] = None, ) -> None: """Initialize the async WorkOS client. @@ -625,9 +695,15 @@ def __init__( request_timeout: HTTP request timeout in seconds. Falls back to WORKOS_REQUEST_TIMEOUT or 60. jwt_leeway: JWT clock skew leeway in seconds. max_retries: Maximum number of retries for failed requests. Defaults to 3. + http_client: HTTP client to send requests with. Accepts an + ``httpx2.AsyncClient``, an ``httpx.AsyncClient``, or any object + implementing :class:`workos.AsyncHTTPBackend`. Defaults to a new + ``httpx2.AsyncClient`` that this instance owns and closes. A + client you pass in is never closed by the SDK. Raises: ValueError: If neither api_key nor client_id is provided, directly or via environment variables. + TypeError: If ``http_client`` is not a supported client type. """ super().__init__( api_key=api_key, @@ -638,13 +714,17 @@ def __init__( max_retries=max_retries, is_public=is_public, ) - self._client = httpx.AsyncClient( - timeout=self._request_timeout, follow_redirects=True + self._backend, self._owns_http_client = resolve_async_backend( + http_client, self._request_timeout ) async def close(self) -> None: - """Close the underlying HTTP client and release resources.""" - await self._client.aclose() + """Release the HTTP client if the SDK created it. + + A client passed in via ``http_client`` stays open; close it yourself. + """ + if self._owns_http_client: + await self._backend.close() async def __aenter__(self) -> "AsyncWorkOSClient": return self @@ -690,19 +770,19 @@ async def request( request_options: Optional[RequestOptions] = None, ) -> Any: """Make an async HTTP request with retry logic.""" - url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}" + url = self._build_request_url(path, params, request_options) headers = self._build_headers(method, idempotency_key, request_options) + content = self._encode_body(body) timeout = self._resolve_timeout(request_options) max_retries = self._resolve_max_retries(request_options) last_error: Optional[Exception] = None for attempt in range(max_retries + 1): try: - response = await self._client.request( - method=method.upper(), - url=url, - params=params, - json=body if body is not None else None, + response = await self._backend.request( + method.upper(), + url, headers=headers, + content=content, timeout=timeout, ) if response.status_code in RETRY_STATUS_CODES and attempt < max_retries: @@ -714,19 +794,19 @@ async def request( if response.status_code >= 400: self._raise_error(response) return self._deserialize_response(response, model) - except httpx.TimeoutException as e: + except TransportTimeout as e: last_error = e if attempt < max_retries: await asyncio.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSTimeoutError(f"Request timed out: {e}") from e - except httpx.ConnectError as e: + except TransportConnectError as e: last_error = e if attempt < max_retries: await asyncio.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSConnectionError(f"Connection failed: {e}") from e - except httpx.HTTPError as e: + except TransportError as e: last_error = e if attempt < max_retries: await asyncio.sleep(self._calculate_retry_delay(attempt)) diff --git a/src/workos/_http.py b/src/workos/_http.py new file mode 100644 index 00000000..a2368d21 --- /dev/null +++ b/src/workos/_http.py @@ -0,0 +1,315 @@ +# @oagen-ignore-file +"""HTTP transport layer for the WorkOS client. + +``_base_client`` owns URL building, query and body encoding, retries and error +mapping. Everything that touches a concrete HTTP library lives here, behind the +:class:`HTTPBackend` / :class:`AsyncHTTPBackend` protocols, so a caller can +supply any HTTP client they like via ``http_client=``. +""" + +from __future__ import annotations + +import importlib +import inspect +import json +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Mapping, + Optional, + Protocol, + Tuple, + Union, + cast, +) + +import httpx2 + +if TYPE_CHECKING: + import httpx + + +class TransportError(Exception): + """Network-level failure reported by an HTTP backend. + + Backends raise this family instead of their library's own exceptions so the + retry loop in ``_base_client`` never depends on a particular HTTP client. + """ + + +class TransportTimeout(TransportError): + """The request timed out.""" + + +class TransportConnectError(TransportError): + """A connection could not be established.""" + + +@dataclass(frozen=True) +class HTTPResponse: + """Backend-neutral view of an HTTP response. + + ``headers`` must be a case-insensitive mapping (``httpx2.Headers`` and + ``multidict.CIMultiDictProxy`` both qualify): the SDK reads ``Retry-After`` + and ``x-request-id`` without normalising case. + """ + + status_code: int + headers: Mapping[str, str] + content: bytes + request_method: str + request_url: str + + @property + def text(self) -> str: + """Body decoded as UTF-8, replacing undecodable bytes.""" + return self.content.decode("utf-8", errors="replace") + + def json(self) -> Any: + """Body parsed as JSON.""" + return json.loads(self.content) + + +class HTTPBackend(Protocol): + """Synchronous transport used by :class:`workos.WorkOSClient`. + + The SDK passes a fully built URL (path and query already encoded), the final + headers, an optional JSON body as bytes, and a timeout in seconds. Raise + :class:`TransportTimeout`, :class:`TransportConnectError` or + :class:`TransportError` for network failures so the SDK can retry them. + """ + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: ... + + def close(self) -> None: ... + + +class AsyncHTTPBackend(Protocol): + """Asynchronous transport used by :class:`workos.AsyncWorkOSClient`. + + Same contract as :class:`HTTPBackend` with coroutine methods. + """ + + async def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: ... + + async def close(self) -> None: ... + + +if TYPE_CHECKING: + SyncHTTPClient = Union[httpx2.Client, httpx.Client, HTTPBackend] + AsyncHTTPClient = Union[httpx2.AsyncClient, httpx.AsyncClient, AsyncHTTPBackend] + + +# --- httpx family ------------------------------------------------------------- + +_HTTPX_MODULES = frozenset({"httpx2", "httpx"}) + + +def _httpx_root(obj: object) -> Optional[str]: + """Return ``"httpx2"`` or ``"httpx"`` if ``obj`` is (a subclass of) a client from that module.""" + for cls in type(obj).__mro__: + root = cls.__module__.partition(".")[0] + if root in _HTTPX_MODULES: + return root + return None + + +class _HttpxFamily: + """Classes from whichever httpx-compatible module a client was built with. + + ``httpx2`` is a fork of ``httpx`` 0.28 with an identical API, so one adapter + serves both. Only the exception and client classes differ by module. + """ + + def __init__(self, module_name: str) -> None: + module = importlib.import_module(module_name) + self.name = module_name + self.client: type[Any] = module.Client + self.async_client: type[Any] = module.AsyncClient + self.timeout_error: type[Exception] = module.TimeoutException + self.connect_error: type[Exception] = module.ConnectError + self.http_error: type[Exception] = module.HTTPError + + +def _wrap_response(response: Union[httpx2.Response, httpx.Response]) -> HTTPResponse: + request = response.request + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + content=response.content, + request_method=request.method, + request_url=str(request.url), + ) + + +class HttpxBackend: + """Adapter for ``httpx2.Client`` and the API-identical ``httpx.Client``.""" + + def __init__(self, client: Union[httpx2.Client, httpx.Client]) -> None: + self._client = client + self._family = _HttpxFamily(_httpx_root(client) or "httpx2") + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + try: + response = self._client.request( + method, url, headers=headers, content=content, timeout=timeout + ) + except self._family.timeout_error as exc: + raise TransportTimeout(str(exc)) from exc + except self._family.connect_error as exc: + raise TransportConnectError(str(exc)) from exc + except self._family.http_error as exc: + raise TransportError(str(exc)) from exc + return _wrap_response(response) + + def close(self) -> None: + self._client.close() + + +class AsyncHttpxBackend: + """Adapter for ``httpx2.AsyncClient`` and the API-identical ``httpx.AsyncClient``.""" + + def __init__(self, client: Union[httpx2.AsyncClient, httpx.AsyncClient]) -> None: + self._client = client + self._family = _HttpxFamily(_httpx_root(client) or "httpx2") + + async def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + try: + response = await self._client.request( + method, url, headers=headers, content=content, timeout=timeout + ) + except self._family.timeout_error as exc: + raise TransportTimeout(str(exc)) from exc + except self._family.connect_error as exc: + raise TransportConnectError(str(exc)) from exc + except self._family.http_error as exc: + raise TransportError(str(exc)) from exc + return _wrap_response(response) + + async def close(self) -> None: + await self._client.aclose() + + +# --- resolution ----------------------------------------------------------------- + + +def _describe(obj: object) -> str: + return f"{type(obj).__module__}.{type(obj).__qualname__}" + + +def _is_backend_like(obj: object) -> bool: + return callable(getattr(obj, "request", None)) and callable( + getattr(obj, "close", None) + ) + + +def _has_async_request(obj: object) -> bool: + return inspect.iscoroutinefunction(getattr(obj, "request", None)) + + +_UNSUPPORTED = ( + "Unsupported http_client {desc}: pass an httpx2 client, an httpx client, or an " + "object implementing workos.{protocol} (request() and close())." +) + + +def resolve_sync_backend( + http_client: Optional[SyncHTTPClient], timeout: float +) -> Tuple[HTTPBackend, bool]: + """Return ``(backend, owned)`` for :class:`workos.WorkOSClient`. + + ``owned`` is True only for the default client the SDK creates itself. The + SDK never closes a client the caller passed in. + """ + if http_client is None: + client = httpx2.Client(timeout=timeout, follow_redirects=True) + return HttpxBackend(client), True + root = _httpx_root(http_client) + if root is not None: + family = _HttpxFamily(root) + if isinstance(http_client, family.client): + return HttpxBackend(cast(httpx2.Client, http_client)), False + if isinstance(http_client, family.async_client): + raise TypeError( + f"WorkOSClient needs a synchronous HTTP client but got " + f"{root}.AsyncClient; pass {root}.Client, or use AsyncWorkOSClient." + ) + if _is_backend_like(http_client): + if _has_async_request(http_client): + raise TypeError( + "WorkOSClient needs an HTTPBackend with a synchronous request(); " + f"{_describe(http_client)}.request is a coroutine function. " + "Use AsyncWorkOSClient." + ) + return cast(HTTPBackend, http_client), False + raise TypeError( + _UNSUPPORTED.format(desc=_describe(http_client), protocol="HTTPBackend") + ) + + +def resolve_async_backend( + http_client: Optional[AsyncHTTPClient], timeout: float +) -> Tuple[AsyncHTTPBackend, bool]: + """Return ``(backend, owned)`` for :class:`workos.AsyncWorkOSClient`. + + ``owned`` is True only for the default client the SDK creates itself. The + SDK never closes a client the caller passed in. + """ + if http_client is None: + client = httpx2.AsyncClient(timeout=timeout, follow_redirects=True) + return AsyncHttpxBackend(client), True + root = _httpx_root(http_client) + if root is not None: + family = _HttpxFamily(root) + if isinstance(http_client, family.async_client): + return AsyncHttpxBackend(cast(httpx2.AsyncClient, http_client)), False + if isinstance(http_client, family.client): + raise TypeError( + f"AsyncWorkOSClient needs an asynchronous HTTP client but got " + f"{root}.Client; pass {root}.AsyncClient, or use WorkOSClient." + ) + if _is_backend_like(http_client): + if not _has_async_request(http_client): + raise TypeError( + "AsyncWorkOSClient needs an AsyncHTTPBackend whose request() is a " + f"coroutine function; {_describe(http_client)}.request is not. " + "Use WorkOSClient." + ) + return cast(AsyncHTTPBackend, http_client), False + raise TypeError( + _UNSUPPORTED.format(desc=_describe(http_client), protocol="AsyncHTTPBackend") + ) diff --git a/src/workos/public_client.py b/src/workos/public_client.py index b4bca338..45ad907d 100644 --- a/src/workos/public_client.py +++ b/src/workos/public_client.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from ._client import WorkOSClient + from ._http import SyncHTTPClient def create_public_client( @@ -15,6 +16,7 @@ def create_public_client( client_id: str, base_url: Optional[str] = None, request_timeout: Optional[int] = None, + http_client: Optional["SyncHTTPClient"] = None, ) -> "WorkOSClient": """Create a WorkOS client configured for public/PKCE-only usage. @@ -26,6 +28,7 @@ def create_public_client( client_id: The WorkOS client ID. base_url: Override the base URL. Defaults to ``https://api.workos.com``. request_timeout: HTTP request timeout in seconds. + http_client: HTTP client to send requests with; see :class:`workos.WorkOSClient`. Returns: A WorkOSClient instance with only ``client_id`` configured. @@ -38,4 +41,5 @@ def create_public_client( base_url=base_url, request_timeout=request_timeout, is_public=True, + http_client=http_client, ) diff --git a/tests/conftest.py b/tests/conftest.py index 065ed01c..52bbc3f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,10 @@ # @oagen-ignore-file +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union + +import httpx2 import pytest import pytest_asyncio @@ -26,3 +31,87 @@ async def async_workos(): yield client finally: await client.close() + + +class HTTPXMock: + """Stand-in for the ``pytest-httpx`` fixture of the same name. + + ``pytest-httpx`` pins ``httpx==0.28.*`` and cannot intercept ``httpx2``. The + oagen-generated test files depend on this fixture's name and on the subset of + its API implemented here, so the shim is hand-maintained instead of changing + the emitter. Requests are intercepted the same way ``pytest-httpx`` does, by + patching the transport classes, so clients constructed directly in a test are + covered too. Responses are consumed first-in first-out. Unlike ``pytest-httpx`` + the shim does not assert at teardown that every queued response was used. + """ + + def __init__(self) -> None: + self._queue: List[Union[httpx2.Response, Exception]] = [] + self._requests: List[httpx2.Request] = [] + + def add_response( + self, + *, + json: Any = None, + status_code: int = 200, + headers: Optional[Dict[str, str]] = None, + content: Optional[bytes] = None, + ) -> None: + self._queue.append( + httpx2.Response(status_code, json=json, headers=headers, content=content) + ) + + def add_exception(self, exception: Exception) -> None: + self._queue.append(exception) + + def get_requests(self) -> List[httpx2.Request]: + return list(self._requests) + + def get_request(self) -> Optional[httpx2.Request]: + if len(self._requests) > 1: + raise AssertionError( + f"{len(self._requests)} requests were sent; use get_requests()" + ) + return self._requests[0] if self._requests else None + + def reset(self) -> None: + self._queue.clear() + self._requests.clear() + + def _next(self, request: httpx2.Request) -> httpx2.Response: + self._requests.append(request) + if not self._queue: + raise httpx2.TimeoutException( + "No response registered for this request", request=request + ) + item = self._queue.pop(0) + if isinstance(item, Exception): + raise item + return item + + def handle_request(self, request: httpx2.Request) -> httpx2.Response: + request.read() + return self._next(request) + + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: + await request.aread() + return self._next(request) + + +@pytest.fixture +def httpx_mock(monkeypatch: pytest.MonkeyPatch) -> HTTPXMock: + mock = HTTPXMock() + + def _sync( + _transport: httpx2.HTTPTransport, request: httpx2.Request + ) -> httpx2.Response: + return mock.handle_request(request) + + async def _async( + _transport: httpx2.AsyncHTTPTransport, request: httpx2.Request + ) -> httpx2.Response: + return await mock.handle_async_request(request) + + monkeypatch.setattr(httpx2.HTTPTransport, "handle_request", _sync) + monkeypatch.setattr(httpx2.AsyncHTTPTransport, "handle_async_request", _async) + return mock diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 52833dbb..e630a96f 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -201,10 +201,10 @@ def test_pagination_importable() -> None: def test_dependencies_available() -> None: """Verify core runtime dependencies are installed and importable.""" import cryptography - import httpx + import httpx2 import jwt - print("✓ Core dependencies available (httpx, cryptography, pyjwt)") + print("✓ Core dependencies available (httpx2, cryptography, pyjwt)") def main() -> int: diff --git a/tests/test_generated_client.py b/tests/test_generated_client.py index 2847631f..3dcdd66f 100644 --- a/tests/test_generated_client.py +++ b/tests/test_generated_client.py @@ -2,7 +2,7 @@ """Client tests: retries, errors, context manager, idempotency.""" -import httpx +import httpx2 as httpx import pytest from workos import WorkOSClient, AsyncWorkOSClient diff --git a/tests/test_http_backends.py b/tests/test_http_backends.py new file mode 100644 index 00000000..649a1280 --- /dev/null +++ b/tests/test_http_backends.py @@ -0,0 +1,453 @@ +# @oagen-ignore-file + +"""HTTP backend protocol, the httpx/httpx2 adapter, resolution, and encoding parity.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Union + +import httpx +import httpx2 +import pytest + +from workos import ( + AsyncWorkOSClient, + HTTPResponse, + NotFoundError, + TransportConnectError, + TransportError, + TransportTimeout, + WorkOSClient, + WorkOSError, +) +from workos import _base_client as base_client_module +from workos._base_client import _BaseWorkOSClient +from workos._errors import WorkOSConnectionError, WorkOSTimeoutError +from workos._http import AsyncHttpxBackend, HttpxBackend + +API_KEY = "sk_test_123" +BASE = "https://api.workos.com" + + +def make_response( + status_code: int = 200, + body: Any = None, + headers: Optional[Dict[str, str]] = None, + method: str = "GET", + url: str = f"{BASE}/t", +) -> HTTPResponse: + content = b"" if body is None else json.dumps(body).encode() + return HTTPResponse( + status_code=status_code, + headers=httpx2.Headers(headers or {}), + content=content, + request_method=method, + request_url=url, + ) + + +class FakeBackend: + """Minimal HTTPBackend that replays queued responses or exceptions.""" + + def __init__(self, *items: Union[HTTPResponse, Exception]) -> None: + self.items: List[Union[HTTPResponse, Exception]] = list(items) + self.calls: List[Dict[str, Any]] = [] + self.closed = False + + def _record( + self, + method: str, + url: str, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + self.calls.append( + { + "method": method, + "url": url, + "headers": headers, + "content": content, + "timeout": timeout, + } + ) + item = self.items.pop(0) + if isinstance(item, Exception): + raise item + return item + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + return self._record(method, url, headers, content, timeout) + + def close(self) -> None: + self.closed = True + + +class AsyncFakeBackend(FakeBackend): + """Async twin of FakeBackend.""" + + async def request( # type: ignore[override] + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + return self._record(method, url, headers, content, timeout) + + async def close(self) -> None: # type: ignore[override] + self.closed = True + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(base_client_module.time, "sleep", lambda _: None) + + +class TestProtocolBackend: + def test_receives_full_url_headers_body_and_timeout(self) -> None: + backend = FakeBackend(make_response(200, {"ok": True})) + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + result = client.request( + "POST", + ("orgs", "org 1"), + params={"limit": 10, "enabled": True, "tags": ["a", "b"]}, + body={"name": "Acme"}, + request_options={"timeout": 7}, + ) + + assert result == {"ok": True} + call = backend.calls[0] + assert call["method"] == "POST" + assert call["url"] == f"{BASE}/orgs/org%201?limit=10&enabled=true&tags=a&tags=b" + assert call["content"] == b'{"name":"Acme"}' + assert call["headers"]["Content-Type"] == "application/json" + assert call["headers"]["Authorization"] == f"Bearer {API_KEY}" + assert call["timeout"] == 7.0 + + def test_no_params_and_no_body(self) -> None: + backend = FakeBackend(make_response(204)) + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + assert client.request("GET", ("t",)) is None + assert backend.calls[0]["url"] == f"{BASE}/t" + assert backend.calls[0]["content"] is None + + def test_retries_retryable_status_then_succeeds(self) -> None: + backend = FakeBackend( + make_response(429, {"message": "slow"}, {"Retry-After": "0"}), + make_response(200, {"ok": True}), + ) + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + assert client.request("GET", ("t",)) == {"ok": True} + assert len(backend.calls) == 2 + + def test_client_error_is_raised_not_retried(self) -> None: + backend = FakeBackend( + make_response(404, {"message": "missing"}, {"X-Request-Id": "req_1"}) + ) + client = WorkOSClient(api_key=API_KEY, http_client=backend, max_retries=3) + + with pytest.raises(NotFoundError) as exc_info: + client.request("GET", ("t",)) + + assert exc_info.value.request_id == "req_1" + assert exc_info.value.request_url == f"{BASE}/t" + assert exc_info.value.request_method == "GET" + assert len(backend.calls) == 1 + + @pytest.mark.parametrize( + ("transport_error", "sdk_error", "message"), + [ + (TransportTimeout("slow"), WorkOSTimeoutError, "Request timed out: slow"), + ( + TransportConnectError("refused"), + WorkOSConnectionError, + "Connection failed: refused", + ), + (TransportError("broken"), WorkOSError, "Network error: broken"), + ], + ) + def test_transport_errors_are_mapped( + self, transport_error: Exception, sdk_error: type, message: str + ) -> None: + backend = FakeBackend(transport_error) + client = WorkOSClient(api_key=API_KEY, http_client=backend, max_retries=0) + + with pytest.raises(sdk_error) as exc_info: + client.request("GET", ("t",)) + + assert str(exc_info.value) == message + assert exc_info.value.__cause__ is transport_error + + def test_transport_errors_are_retried(self) -> None: + backend = FakeBackend(TransportTimeout("slow"), make_response(200, {"ok": 1})) + client = WorkOSClient(api_key=API_KEY, http_client=backend, max_retries=1) + + assert client.request("GET", ("t",)) == {"ok": 1} + assert len(backend.calls) == 2 + + def test_injected_backend_is_not_closed(self) -> None: + backend = FakeBackend() + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + client.close() + + assert backend.closed is False + + def test_default_client_is_closed(self) -> None: + client = WorkOSClient(api_key=API_KEY) + backend = client._backend + assert isinstance(backend, HttpxBackend) + + client.close() + + assert backend._client.is_closed + + +@pytest.mark.asyncio +class TestAsyncProtocolBackend: + async def test_retries_and_deserializes(self) -> None: + backend = AsyncFakeBackend( + make_response(503, {"message": "down"}, {"Retry-After": "0"}), + make_response(200, {"ok": True}), + ) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend) + + assert await client.request("GET", ("t",)) == {"ok": True} + assert len(backend.calls) == 2 + assert backend.calls[0]["url"] == f"{BASE}/t" + + async def test_transport_timeout_is_mapped(self) -> None: + backend = AsyncFakeBackend(TransportTimeout("slow")) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend, max_retries=0) + + with pytest.raises(WorkOSTimeoutError, match="Request timed out: slow"): + await client.request("GET", ("t",)) + + async def test_client_error_is_raised_not_retried(self) -> None: + backend = AsyncFakeBackend(make_response(404, {"message": "missing"})) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend) + + with pytest.raises(NotFoundError): + await client.request("GET", ("t",)) + + assert len(backend.calls) == 1 + + async def test_injected_backend_is_not_closed(self) -> None: + backend = AsyncFakeBackend() + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend) + + await client.close() + + assert backend.closed is False + + async def test_default_client_is_closed(self) -> None: + client = AsyncWorkOSClient(api_key=API_KEY) + backend = client._backend + assert isinstance(backend, AsyncHttpxBackend) + + await client.close() + + assert backend._client.is_closed + + +MODULES = [pytest.param(httpx2, id="httpx2"), pytest.param(httpx, id="httpx")] + + +@pytest.mark.parametrize("mod", MODULES) +class TestHttpxAdapter: + def test_wraps_response(self, mod: Any) -> None: + seen: Dict[str, Any] = {} + + def handler(request: Any) -> Any: + seen["timeout"] = request.extensions.get("timeout") + seen["content"] = request.content + return mod.Response(200, json={"a": 1}, headers={"X-Request-Id": "req_1"}) + + backend = HttpxBackend(mod.Client(transport=mod.MockTransport(handler))) + response = backend.request( + "POST", "https://x/p?q=1", headers={"H": "v"}, content=b"{}", timeout=5.0 + ) + + assert isinstance(response, HTTPResponse) + assert response.status_code == 200 + assert response.json() == {"a": 1} + assert response.headers.get("x-request-id") == "req_1" + assert response.request_method == "POST" + assert response.request_url == "https://x/p?q=1" + assert seen["content"] == b"{}" + assert seen["timeout"] == { + "connect": 5.0, + "read": 5.0, + "write": 5.0, + "pool": 5.0, + } + + @pytest.mark.parametrize( + ("raised", "expected"), + [ + ("TimeoutException", TransportTimeout), + ("ConnectError", TransportConnectError), + ("RemoteProtocolError", TransportError), + ], + ) + def test_maps_exceptions(self, mod: Any, raised: str, expected: type) -> None: + exc_cls = getattr(mod, raised) + + def handler(request: Any) -> Any: + raise exc_cls("boom") + + backend = HttpxBackend(mod.Client(transport=mod.MockTransport(handler))) + + with pytest.raises(expected) as exc_info: + backend.request("GET", "https://x/p", headers={}, content=None, timeout=1.0) + + assert isinstance(exc_info.value.__cause__, exc_cls) + + def test_end_to_end_through_client(self, mod: Any) -> None: + def handler(request: Any) -> Any: + return mod.Response(404, json={"message": "nope"}) + + http_client = mod.Client(transport=mod.MockTransport(handler)) + client = WorkOSClient(api_key=API_KEY, http_client=http_client) + + with pytest.raises(NotFoundError): + client.request("GET", ("t",)) + client.close() + + assert not http_client.is_closed + http_client.close() + + @pytest.mark.asyncio + async def test_async_end_to_end_through_client(self, mod: Any) -> None: + def handler(request: Any) -> Any: + return mod.Response(200, json={"ok": True}) + + http_client = mod.AsyncClient(transport=mod.MockTransport(handler)) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=http_client) + + assert await client.request("GET", ("t",)) == {"ok": True} + await client.close() + + assert not http_client.is_closed + await http_client.aclose() + + @pytest.mark.asyncio + async def test_async_adapter_maps_timeout(self, mod: Any) -> None: + def handler(request: Any) -> Any: + raise mod.TimeoutException("slow") + + backend = AsyncHttpxBackend( + mod.AsyncClient(transport=mod.MockTransport(handler)) + ) + + with pytest.raises(TransportTimeout): + await backend.request( + "GET", "https://x/p", headers={}, content=None, timeout=1.0 + ) + + +class TestResolution: + def test_default_backend_settings(self) -> None: + client = WorkOSClient(api_key=API_KEY, request_timeout=9) + backend = client._backend + assert isinstance(backend, HttpxBackend) + assert isinstance(backend._client, httpx2.Client) + assert backend._client.follow_redirects is True + assert backend._client.timeout == httpx2.Timeout(9) + client.close() + + def test_httpx_client_subclass_is_detected(self) -> None: + class MyClient(httpx2.Client): + pass + + http_client = MyClient() + client = WorkOSClient(api_key=API_KEY, http_client=http_client) + assert isinstance(client._backend, HttpxBackend) + http_client.close() + + def test_sync_client_rejects_async_httpx_client(self) -> None: + with pytest.raises(TypeError, match="use AsyncWorkOSClient"): + WorkOSClient(api_key=API_KEY, http_client=httpx2.AsyncClient()) # type: ignore[arg-type] + + def test_async_client_rejects_sync_httpx_client(self) -> None: + with pytest.raises(TypeError, match="use WorkOSClient"): + AsyncWorkOSClient(api_key=API_KEY, http_client=httpx2.Client()) # type: ignore[arg-type] + + def test_sync_client_rejects_async_backend(self) -> None: + with pytest.raises(TypeError, match="coroutine"): + WorkOSClient(api_key=API_KEY, http_client=AsyncFakeBackend()) # type: ignore[arg-type] + + def test_async_client_rejects_sync_backend(self) -> None: + with pytest.raises(TypeError, match="coroutine"): + AsyncWorkOSClient(api_key=API_KEY, http_client=FakeBackend()) # type: ignore[arg-type] + + def test_rejects_unrelated_object(self) -> None: + with pytest.raises(TypeError, match="HTTPBackend"): + WorkOSClient(api_key=API_KEY, http_client=object()) # type: ignore[arg-type] + + +class TestEncodingParity: + @pytest.mark.parametrize( + "params", + [ + {"limit": 10}, + {"enabled": True, "archived": False}, + {"after": None}, + {"domains": ["a.com", "b.com"]}, + {"order": ("asc", "desc")}, + {"search": "héllo wörld & co=1"}, + {"empty": ""}, + {"ratio": 1.5}, + {}, + None, + ], + ) + def test_query_matches_httpx(self, params: Optional[Dict[str, Any]]) -> None: + encoded = _BaseWorkOSClient._encode_query(params) + ours = httpx2.URL(f"{BASE}/p?{encoded}" if encoded else f"{BASE}/p") + theirs = httpx2.Request("GET", f"{BASE}/p", params=params).url + + assert ours.params == theirs.params + assert str(ours) == str(theirs) + + def test_query_string_shape(self) -> None: + params = {"a": True, "b": None, "c": ["x", "y"], "d": 5} + assert _BaseWorkOSClient._encode_query(params) == "a=true&b=&c=x&c=y&d=5" + + def test_body_matches_httpx(self) -> None: + body = {"a": 1, "b": [1, 2], "s": "é", "nested": {"k": None}} + assert ( + _BaseWorkOSClient._encode_body(body) + == httpx2.Request("POST", BASE, json=body).content + ) + assert _BaseWorkOSClient._encode_body(None) is None + assert _BaseWorkOSClient._encode_body({}) == b"{}" + + def test_recorded_request_exposes_decoded_params( + self, workos: WorkOSClient, httpx_mock: Any + ) -> None: + httpx_mock.add_response(json={}) + + workos.request( + "GET", ("t",), params={"enabled": True, "limit": 5, "ids": ["a", "b"]} + ) + + request = httpx_mock.get_request() + assert request.url.params["enabled"] == "true" + assert request.url.params["limit"] == "5" + assert request.url.params.get_list("ids") == ["a", "b"] diff --git a/uv.lock b/uv.lock index 9f66a575..3ca7bf04 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' or sys_platform != 'emscripten'", +] [[package]] name = "anyio" @@ -629,6 +633,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -644,6 +661,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "humanize" version = "4.15.0" @@ -655,11 +698,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -1022,19 +1065,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "pytest-httpx" -version = "0.36.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, -] - [[package]] name = "pytokens" version = "0.4.1" @@ -1245,6 +1275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typeapi" version = "2.3.0" @@ -1328,20 +1367,20 @@ version = "10.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "pyjwt" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "nox" }, { name = "nox-uv" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, - { name = "pytest-httpx" }, { name = "ruff" }, ] docs = [ @@ -1357,32 +1396,33 @@ nox = [ { name = "nox-uv" }, ] test = [ + { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, - { name = "pytest-httpx" }, ] type-check = [ + { name = "httpx" }, { name = "pyright" }, ] [package.metadata] requires-dist = [ { name = "cryptography", specifier = "~=50.0" }, - { name = "httpx", specifier = "~=0.28" }, + { name = "httpx2", specifier = "~=2.13" }, { name = "pyjwt", specifier = "~=2.12" }, { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.0" }, ] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = "~=0.28" }, { name = "nox", specifier = "~=2026.2" }, { name = "nox-uv", specifier = "~=0.7" }, { name = "pyright", specifier = "~=1.1" }, { name = "pytest", specifier = "~=9.0" }, { name = "pytest-asyncio", specifier = "~=1.3" }, { name = "pytest-cov", specifier = "~=7.1" }, - { name = "pytest-httpx", specifier = "~=0.36" }, { name = "ruff", specifier = "~=0.15" }, ] docs = [ @@ -1396,12 +1436,15 @@ nox = [ { name = "nox-uv", specifier = "~=0.7" }, ] test = [ + { name = "httpx", specifier = "~=0.28" }, { name = "pytest", specifier = "~=9.0" }, { name = "pytest-asyncio", specifier = "~=1.3" }, { name = "pytest-cov", specifier = "~=7.1" }, - { name = "pytest-httpx", specifier = "~=0.36" }, ] -type-check = [{ name = "pyright", specifier = "~=1.1" }] +type-check = [ + { name = "httpx", specifier = "~=0.28" }, + { name = "pyright", specifier = "~=1.1" }, +] [[package]] name = "wrapt" From 2fbc3556c455893b39221fe91418caaa4a2d7b2c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 16 Sep 2026 12:52:11 -0400 Subject: [PATCH 2/3] test: fail httpx_mock tests that leave queued responses unconsumed pytest-httpx asserted at teardown that every registered response was requested. The replacement fixture dropped that check, so a retry test that queues four responses would pass even if the client stopped after the first. Restore the assertion as a yielding fixture; reset() remains the explicit way to discard a queue. All 2820 existing tests already satisfy it. --- tests/conftest.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 52bbc3f1..9c22d5ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Iterator, List, Optional, Union import httpx2 import pytest @@ -41,8 +41,9 @@ class HTTPXMock: its API implemented here, so the shim is hand-maintained instead of changing the emitter. Requests are intercepted the same way ``pytest-httpx`` does, by patching the transport classes, so clients constructed directly in a test are - covered too. Responses are consumed first-in first-out. Unlike ``pytest-httpx`` - the shim does not assert at teardown that every queued response was used. + covered too. Responses are consumed first-in first-out. Like ``pytest-httpx``, + the fixture fails the test at teardown if any queued response was never + requested; call ``reset()`` to discard the queue deliberately. """ def __init__(self) -> None: @@ -78,6 +79,9 @@ def reset(self) -> None: self._queue.clear() self._requests.clear() + def unused_responses(self) -> int: + return len(self._queue) + def _next(self, request: httpx2.Request) -> httpx2.Response: self._requests.append(request) if not self._queue: @@ -99,7 +103,7 @@ async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response @pytest.fixture -def httpx_mock(monkeypatch: pytest.MonkeyPatch) -> HTTPXMock: +def httpx_mock(monkeypatch: pytest.MonkeyPatch) -> Iterator[HTTPXMock]: mock = HTTPXMock() def _sync( @@ -114,4 +118,9 @@ async def _async( monkeypatch.setattr(httpx2.HTTPTransport, "handle_request", _sync) monkeypatch.setattr(httpx2.AsyncHTTPTransport, "handle_async_request", _async) - return mock + yield mock + unused = mock.unused_responses() + if unused: + pytest.fail( + f"{unused} httpx_mock response(s) were registered but never requested" + ) From a7d27e710672ac000125638167af01cf81771e8a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 16 Sep 2026 18:00:33 -0400 Subject: [PATCH 3/3] fix(http): preserve queries and resolve type hints Custom client defaults could discard SDK filters and pagination cursors, while runtime annotation consumers raised NameError. Both paths must work without requiring the legacy httpx dependency. --- src/workos/_base_client.py | 7 ++- src/workos/_http.py | 30 ++++++++--- src/workos/public_client.py | 9 ++-- tests/test_http_backends.py | 101 ++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index dc0411ef..24a907d4 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -10,7 +10,7 @@ import random from datetime import datetime, timezone from email.utils import parsedate_to_datetime -from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Type, cast, overload +from typing import Any, Dict, Optional, Sequence, Type, cast, overload from urllib.parse import quote, urlencode from ._errors import ( @@ -25,7 +25,9 @@ _AUTH_CODE_TO_ERROR, ) from ._http import ( + AsyncHTTPClient, HTTPResponse, + SyncHTTPClient, TransportConnectError, TransportError, TransportTimeout, @@ -35,9 +37,6 @@ from ._pagination import AsyncPage, ListMetadata, SyncPage from ._types import D, Deserializable, RequestOptions -if TYPE_CHECKING: - from ._http import AsyncHTTPClient, SyncHTTPClient - try: from importlib.metadata import version as _pkg_version diff --git a/src/workos/_http.py b/src/workos/_http.py index a2368d21..13d70dbd 100644 --- a/src/workos/_http.py +++ b/src/workos/_http.py @@ -14,7 +14,6 @@ import json from dataclasses import dataclass from typing import ( - TYPE_CHECKING, Any, Dict, Mapping, @@ -24,11 +23,17 @@ Union, cast, ) +from urllib.parse import urlsplit import httpx2 -if TYPE_CHECKING: +try: import httpx +except ModuleNotFoundError as exc: + if exc.name != "httpx": + raise + # Keep runtime annotations resolvable without the optional legacy package. + import httpx2 as httpx class TransportError(Exception): @@ -113,9 +118,8 @@ async def request( async def close(self) -> None: ... -if TYPE_CHECKING: - SyncHTTPClient = Union[httpx2.Client, httpx.Client, HTTPBackend] - AsyncHTTPClient = Union[httpx2.AsyncClient, httpx.AsyncClient, AsyncHTTPBackend] +SyncHTTPClient = Union[httpx2.Client, httpx.Client, HTTPBackend] +AsyncHTTPClient = Union[httpx2.AsyncClient, httpx.AsyncClient, AsyncHTTPBackend] # --- httpx family ------------------------------------------------------------- @@ -178,7 +182,13 @@ def request( ) -> HTTPResponse: try: response = self._client.request( - method, url, headers=headers, content=content, timeout=timeout + method, + url, + # Explicit params merge defaults instead of replacing the URL query. + params=urlsplit(url).query or None, + headers=headers, + content=content, + timeout=timeout, ) except self._family.timeout_error as exc: raise TransportTimeout(str(exc)) from exc @@ -210,7 +220,13 @@ async def request( ) -> HTTPResponse: try: response = await self._client.request( - method, url, headers=headers, content=content, timeout=timeout + method, + url, + # Explicit params merge defaults instead of replacing the URL query. + params=urlsplit(url).query or None, + headers=headers, + content=content, + timeout=timeout, ) except self._family.timeout_error as exc: raise TransportTimeout(str(exc)) from exc diff --git a/src/workos/public_client.py b/src/workos/public_client.py index 45ad907d..e4ee11e9 100644 --- a/src/workos/public_client.py +++ b/src/workos/public_client.py @@ -4,11 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import Optional -if TYPE_CHECKING: - from ._client import WorkOSClient - from ._http import SyncHTTPClient +from ._client import WorkOSClient +from ._http import SyncHTTPClient def create_public_client( @@ -33,8 +32,6 @@ def create_public_client( Returns: A WorkOSClient instance with only ``client_id`` configured. """ - from ._client import WorkOSClient - return WorkOSClient( api_key=None, client_id=client_id, diff --git a/tests/test_http_backends.py b/tests/test_http_backends.py index 649a1280..5c27a9e6 100644 --- a/tests/test_http_backends.py +++ b/tests/test_http_backends.py @@ -5,6 +5,8 @@ from __future__ import annotations import json +import subprocess +import sys from typing import Any, Dict, List, Optional, Union import httpx @@ -25,6 +27,7 @@ from workos._base_client import _BaseWorkOSClient from workos._errors import WorkOSConnectionError, WorkOSTimeoutError from workos._http import AsyncHttpxBackend, HttpxBackend +from tests.generated_helpers import load_fixture API_KEY = "sk_test_123" BASE = "https://api.workos.com" @@ -346,6 +349,56 @@ def handler(request: Any) -> Any: assert not http_client.is_closed await http_client.aclose() + @pytest.mark.asyncio + @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) + async def test_client_query_defaults_preserve_filters_and_pagination( + self, mod: Any, asynchronous: bool + ) -> None: + requests: List[Any] = [] + first_page = load_fixture("list_user.json") + first_page["list_metadata"]["after"] = "cursor/after" + + def handler(request: Any) -> Any: + requests.append(request) + body = ( + first_page if len(requests) == 1 else {"data": [], "list_metadata": {}} + ) + return mod.Response(200, json=body) + + defaults = {"limit": 100, "order": "asc", "organization_id": "org_default"} + http_cls = mod.AsyncClient if asynchronous else mod.Client + http_client = http_cls(params=defaults, transport=mod.MockTransport(handler)) + try: + if asynchronous: + client = AsyncWorkOSClient(api_key=API_KEY, http_client=http_client) + page = await client.user_management.list_users( + email="alice@example.com", limit=7 + ) + users = [user async for user in page] + else: + sync_client = WorkOSClient(api_key=API_KEY, http_client=http_client) + sync_page = sync_client.user_management.list_users( + email="alice@example.com", limit=7 + ) + users = list(sync_page) + finally: + if asynchronous: + await http_client.aclose() + else: + http_client.close() + + expected = { + "limit": "7", + "order": "desc", + "organization_id": "org_default", + "email": "alice@example.com", + } + assert len(users) == 1 + assert len(requests) == 2 + assert dict(requests[0].url.params) == expected + assert dict(requests[1].url.params) == {**expected, "after": "cursor/after"} + assert http_client.params == mod.QueryParams(defaults) + @pytest.mark.asyncio async def test_async_adapter_maps_timeout(self, mod: Any) -> None: def handler(request: Any) -> Any: @@ -362,6 +415,47 @@ def handler(request: Any) -> Any: class TestResolution: + @pytest.mark.parametrize( + "httpx_installed", [False, True], ids=["without-httpx", "with-httpx"] + ) + def test_public_client_annotations_resolve_at_runtime( + self, httpx_installed: bool + ) -> None: + script = """ +import sys +from typing import get_args, get_type_hints + +if sys.argv[1] == "False": + sys.modules["httpx"] = None + +import httpx2 +from workos import ( + AsyncHTTPBackend, AsyncWorkOSClient, HTTPBackend, WorkOSClient, create_public_client, +) + +for factory, client_type, protocol in ( + (WorkOSClient.__init__, httpx2.Client, HTTPBackend), + (AsyncWorkOSClient.__init__, httpx2.AsyncClient, AsyncHTTPBackend), + (create_public_client, httpx2.Client, HTTPBackend), +): + client_types = get_args(get_type_hints(factory)["http_client"]) + assert client_type in client_types + assert protocol in client_types + assert type(None) in client_types + if sys.argv[1] == "True": + import httpx + assert getattr(httpx, client_type.__name__) in client_types + +assert get_type_hints(create_public_client)["return"] is WorkOSClient +""" + result = subprocess.run( + [sys.executable, "-c", script, str(httpx_installed)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + def test_default_backend_settings(self) -> None: client = WorkOSClient(api_key=API_KEY, request_timeout=9) backend = client._backend @@ -424,6 +518,13 @@ def test_query_matches_httpx(self, params: Optional[Dict[str, Any]]) -> None: assert ours.params == theirs.params assert str(ours) == str(theirs) + with httpx2.Client( + transport=httpx2.MockTransport(lambda _: httpx2.Response(200)) + ) as client: + response = HttpxBackend(client).request( + "GET", str(ours), headers={}, content=None, timeout=1.0 + ) + assert response.request_url == str(theirs) def test_query_string_shape(self) -> None: params = {"a": True, "b": None, "c": ["x", "y"], "d": 5}