feat: switch default HTTP client to httpx2 and accept a custom http_client - #731
gjtorikian wants to merge 3 commits into
Conversation
…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
|
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.
cb998ec to
2fbc355
Compare
birdcar
left a comment
There was a problem hiding this comment.
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.
| ) -> HTTPResponse: | ||
| try: | ||
| response = self._client.request( | ||
| method, url, headers=headers, content=content, timeout=timeout |
There was a problem hiding this comment.
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="alice@example.com")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.
| if TYPE_CHECKING: | ||
| SyncHTTPClient = Union[httpx2.Client, httpx.Client, HTTPBackend] | ||
| AsyncHTTPClient = Union[httpx2.AsyncClient, httpx.AsyncClient, AsyncHTTPBackend] |
There was a problem hiding this comment.
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 definedThe 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.
Summary
Replaces the
httpxdependency withhttpx2(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.13replaceshttpx~=0.28independencies.httpxis no longer installed transitively.src/workos/_http.py:HTTPResponse, theHTTPBackend/AsyncHTTPBackendprotocols,TransportError/TransportTimeout/TransportConnectError, and one adapter that serves bothhttpx2andhttpx0.28 clients. All exported fromworkos.WorkOSClient,AsyncWorkOSClient, andcreate_public_clienttakehttp_client=. Accepts an httpx2 client, an httpx client, or any object implementing the protocol. A wrong-flavor client (sync vs async) raises aTypeErrorthat names the fix. The SDK closes only clients it created._base_client.pyso every backend sends identical bytes (verified byte-for-byte against httpx2).pytest-httpxpinshttpx==0.28.*and cannot intercept httpx2. It is replaced by a hand-maintainedhttpx_mockfixture intests/conftest.pythat preserves the API the 24 oagen-generated test files use. Those files are untouched.Refs #730. The aiohttp extra requested there is intentionally not shipped:
httpx2stays 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
httpxnever appeared in a public signature: the constructors took no HTTP client, and network errors were already rethrown asWorkOSTimeoutError/WorkOSConnectionError/WorkOSError.docs/V6_MIGRATION_GUIDE.mdstates 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_FILEandSSL_CERT_DIRare honored exactly as before. Only an image with no system CA bundle at all is affected.pytest-httpxorrespxWorkOSClient(..., http_client=httpx.Client())in test setupimport httpxwithout declaring itpip install -U workoskeeps it)httpxanyio<4.10oridna<3.18httpx2,httpcore2,truststoreneed approvalclient._clientor filter thehttpxloggerAttributeError; request logs now come from loggerhttpx2http_client=; filterhttpx2Test plan
ruff format --checkandruff check: cleanpyright(src and tests, strict): 0 errorspytest: 2820 passed, generated test files unchangeduv buildthentests/smoke_test.pyagainst the wheel in an isolated env: 11/11,httpxnot installedtests/test_http_backends.py: protocol fake (retry, 4xx never retried, error mapping, close ownership), httpx adapter parametrized overhttpx2andhttpx, resolution errors, encoding parity against httpx2