Skip to content

feat: switch default HTTP client to httpx2 and accept a custom http_client - #731

Merged
gjtorikian merged 3 commits into
mainfrom
feat/httpx2-http-client
Sep 17, 2026
Merged

gjtorikian merged 3 commits into
mainfrom
feat/httpx2-http-client

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the httpx dependency with httpx2 (the Pydantic-stewarded fork of httpx 0.28.1 with an identical API) and puts the HTTP layer behind a small protocol so callers can pass their own client.

  • httpx2~=2.13 replaces httpx~=0.28 in dependencies. httpx is no longer installed transitively.
  • New hand-maintained src/workos/_http.py: HTTPResponse, the HTTPBackend / AsyncHTTPBackend protocols, TransportError / TransportTimeout / TransportConnectError, and one adapter that serves both httpx2 and httpx 0.28 clients. All exported from workos.
  • WorkOSClient, AsyncWorkOSClient, and create_public_client take http_client=. Accepts an httpx2 client, an httpx client, or any object implementing the protocol. A wrong-flavor client (sync vs async) raises a TypeError that names the fix. The SDK closes only clients it created.
  • Query and JSON body encoding move into _base_client.py so every backend sends identical bytes (verified byte-for-byte against httpx2).
  • Tests: pytest-httpx pins httpx==0.28.* and cannot intercept httpx2. It is replaced by a hand-maintained httpx_mock fixture in tests/conftest.py that preserves the API the 24 oagen-generated test files use. Those files are untouched.
  • README gains an "HTTP Backends" section, including a reference aiohttp adapter labelled as an example.

Closes #730. The aiohttp extra requested there is intentionally not shipped: httpx2 stays a hard dependency for the sync client and the default, so an in-tree aiohttp adapter would only buy session reuse at the cost of an optional-dependency matrix. The protocol makes it a small add-on if demand appears.

Why this is a minor release

httpx never appeared in a public signature: the constructors took no HTTP client, and network errors were already rethrown as WorkOSTimeoutError / WorkOSConnectionError / WorkOSError. docs/V6_MIGRATION_GUIDE.md states raw httpx exceptions are not part of the contract. Dependency changes have shipped as non-breaking here before (cryptography v48, pyjwt 2.12).

Compatibility notes

For code that only calls the SDK this is transparent: same requests, retries, and exceptions.

One runtime change: httpx2 verifies TLS against the operating system trust store (truststore) instead of certifi's bundle. SSL_CERT_FILE and SSL_CERT_DIR are honored exactly as before. Only an image with no system CA bundle at all is affected.

If you... Then... Fix
mock WorkOS calls in tests with pytest-httpx or respx mocks no longer intercept WorkOSClient(..., http_client=httpx.Client()) in test setup
import httpx without declaring it missing on fresh installs or a re-lock (an in-place pip install -U workos keeps it) declare httpx
pin anyio<4.10 or idna<3.18 resolver error at install time relax the pin
install from a curated mirror httpx2, httpcore2, truststore need approval approve them, or stay on 10.3
reach into client._client or filter the httpx logger AttributeError; request logs now come from logger httpx2 use http_client=; filter httpx2

Test plan

  • ruff format --check and ruff check: clean
  • pyright (src and tests, strict): 0 errors
  • pytest: 2820 passed, generated test files unchanged
  • uv build then tests/smoke_test.py against the wheel in an isolated env: 11/11, httpx not installed
  • New tests/test_http_backends.py: protocol fake (retry, 4xx never retried, error mapping, close ownership), httpx adapter parametrized over httpx2 and httpx, resolution errors, encoding parity against httpx2

…lient

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
@gjtorikian
gjtorikian requested review from a team as code owners September 16, 2026 15:36
@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no actionable new findings or outstanding previous findings.

Summary

Switches the default HTTP transport to httpx2 and introduces synchronous and asynchronous backend protocols for caller-supplied clients.

  • Centralizes request encoding and preserves SDK retry/error handling behind transport adapters.
  • Keeps caller-supplied clients open while closing SDK-owned clients.
  • Latest changes preserve SDK query parameters alongside custom-client defaults and make public type annotations resolvable at runtime.
  • Adds regression coverage for pagination, encoding, and optional-httpx annotation resolution. The previously reported mock-response teardown validation is now present.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[WorkOS sync or async client] --> B[Build URL, headers, and JSON bytes]
    B --> C[Retry loop]
    C --> D{Selected backend}
    D --> E[Default httpx2 adapter]
    D --> F[Caller-supplied httpx or httpx2 adapter]
    D --> G[Custom protocol implementation]
    E --> H[HTTPResponse or transport exception]
    F --> H
    G --> H
    H --> I[Deserialize, map errors, or retry]
Loading

Reviews (4) · Last reviewed commit: "fix(http): preserve queries and resolve ..."

Comment thread tests/conftest.py Outdated
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.
@gjtorikian
gjtorikian force-pushed the feat/httpx2-http-client branch from cb998ec to 2fbc355 Compare September 16, 2026 19:19

@birdcar birdcar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I say "ship it", there are two nits that I don't think are blocking so I'm dropping this as a comment and can flip to approval whenever you say the word.

Comment thread src/workos/_http.py Outdated
) -> HTTPResponse:
try:
response = self._client.request(
method, url, headers=headers, content=content, timeout=timeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This current construction would mean that the SDK's query would be overridden entirely when a user-supplied client has default params, right? Both httpx and httpx2 replace the query embedded in url with the client's defaults here, rather than merging them. For example:

transport = httpx2.Client(params={"limit": 100})
client = WorkOSClient(api_key="sk_...", http_client=transport)
client.user_management.list_users(email="[email protected]")

From my brief look, this would only send ?limit=100 and would silently drop the email filter (and the SDK's default order). I think that pagination cursors would be dropped the same way, so auto-pagination would keep fetching the first page.

It's possible we don't care and this is a "if you're gonna use a custom transport then you should explicitly pass every param" thing.

Comment thread src/workos/_http.py Outdated
Comment on lines +116 to +118
if TYPE_CHECKING:
SyncHTTPClient = Union[httpx2.Client, httpx.Client, HTTPBackend]
AsyncHTTPClient = Union[httpx2.AsyncClient, httpx.AsyncClient, AsyncHTTPBackend]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super small nit: could these aliases also be defined at runtime, and imported normally in _base_client.py? They currently appear in public annotations, but both their definitions and imports are guarded by TYPE_CHECKING. As a result:

from typing import get_type_hints
from workos import WorkOSClient

get_type_hints(WorkOSClient.__init__)
# NameError: name 'SyncHTTPClient' is not defined

The async constructor similarly fails on AsyncHTTPClient (i.e. both constructors' annotations resolve before this change). Normal SDK calls still work, but runtime annotation consumers would error if I'm thinking about this correctly.

Again, maybe not a concern we want to block shipping this change for, but something worth considering.

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.
@gjtorikian
gjtorikian merged commit ce01fae into main Sep 17, 2026
11 checks passed
@gjtorikian
gjtorikian deleted the feat/httpx2-http-client branch September 17, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Replace httpx dependency with httpx2, add optional aiohttp support

2 participants