feat(client): opt-in httpx2 backend via an openai[httpx2] extra#3479
feat(client): opt-in httpx2 backend via an openai[httpx2] extra#3479peterlundberg wants to merge 10 commits into
Conversation
httpx2 is Pydantic's maintained fork of the now-stalled httpx. This extra lets users opt in via `pip install openai[httpx2]`, gated to Python >=3.10 (httpx2 drops 3.9) using the same version-marker pattern as the bedrock extra.
httpx2 is a separate library from httpx (its own client, exception hierarchy and Response), so classic isinstance/except checks don't see it. Centralise httpx2 handling in _httpx2_compat.py and widen the checks in _base_client.py to both backends: coerce the request URL for httpx2 (which rejects a classic httpx.URL) and treat httpx2's own default timeout as "uncustomised" so a bare httpx2 client still gets the SDK's 600s default. No behaviour change unless the httpx2 extra is installed.
Exercises the httpx2 path end-to-end via httpx2.MockTransport: the isinstance gate, the 600s timeout footgun fix (incl. a bare httpx2.Client), classic URL->str coercion, the widened exception tuples (status/timeout/connect mapping), and retries. Skips cleanly when httpx2 is absent (Python 3.9).
Adds a README "With httpx2" section (sync + async usage, plus the Python>=3.10, raw-stream-exception, and cast_to caveats) and examples/httpx2_client.py, mirroring the aiohttp docs.
Subprocess test forcing httpx2 absent: importing openai still works, the DefaultHttpx2Client factories raise a helpful RuntimeError, and a classic httpx client is still accepted. Runs on any Python, including 3.9 where the extra can't be installed.
The httpx2 tests are skipped by every default session (the 3.9-floored dev lock has no httpx2), so a regression in the exception tuples, timeout handling or URL coercion could ship undetected. Add a `test-httpx2` nox session that installs the extra on Python 3.10+ and runs the suite for real. Also document, with explicit tests, that a client left at httpx2's own 5s default is treated as uncustomised (parity with classic httpx; pass timeout= to OpenAI for a real 5s), while a genuinely non-default timeout is respected.
The exception-widening sweep missed one site: Bedrock SigV4 signing reads request.content, and with an httpx2 backend an unbuffered body raises httpx2's own RequestNotRead, which escaped as a raw httpx2 exception instead of the actionable OpenAIError. Widen it via a REQUEST_NOT_READ_ERRORS tuple in the compat module, like the other exception tuples. Also name both accepted client types in the http_client TypeError when the httpx2 extra is installed, so a rejected caller learns httpx2.Client is an option too.
The structural timeout check treats a client whose timeout equals the library default (httpx2's 5s) as not customised and applies the SDK's 600s default -- intentional parity with classic httpx, but surprising if you set Timeout(5.0) deliberately. Document the workaround.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 971a7deec9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| SYNC_HTTP_CLIENT_TYPES = (httpx.Client, _h2.Client) | ||
| ASYNC_HTTP_CLIENT_TYPES = (httpx.AsyncClient, _h2.AsyncClient) |
There was a problem hiding this comment.
Avoid accepting httpx2 before provider auth is compatible
When httpx2 is installed, adding _h2.Client/_h2.AsyncClient here lets provider clients such as BedrockOpenAI(..., http_client=DefaultHttpx2Client()) pass validation, but OpenAI._custom_auth still sends a request-level httpx.Auth() for any provider runtime. httpx2 rebuilds request auth using its own auth types and rejects that classic-httpx auth object, so provider-backed requests fail before reaching the transport instead of using the advertised httpx2 backend.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed, and slightly worse in practice: the TypeError was swallowed by the SDK's generic exception handling and surfaced as a retried APIConnectionError. I think fixed
Provider runtimes pass a bare httpx.Auth() to send() so their request
hooks own authentication, but httpx2's send() rejects a classic
httpx.Auth ('Invalid "auth" argument'), and the SDK's generic exception
handling surfaced that as a misleading, retried APIConnectionError.
noop_auth_for() returns an Auth from the client's own backend.
Found by Codex automated review on the draft PR.
Changes being requested
An opt-in
openai[httpx2]extra, mirroring the ergonomics of the acceptedopenai[aiohttp]extra:
DefaultAsyncHttpx2Clientmirrors it forAsyncOpenAI.Design
_httpx2_compat.py(new leaf module, guardedtry: import httpx2) centralises everything:client-type tuples, exception tuples (
TIMEOUT_EXCEPTIONS,HTTP_STATUS_ERRORS,STREAM_CONSUMED_ERRORS,REQUEST_NOT_READ_ERRORS), a URL-coercion helper,HTTPX2_DEFAULT_TIMEOUT, human-readable accepted-type names for error messages, andDefault{,Async}Httpx2Client(withRuntimeErrorstubs andTYPE_CHECKINGaliases tohttpx.*when the extra is absent — the same pattern asDefaultAioHttpClient).Other files then need only substitutions:
isinstance(http_client, httpx.{,Async}Client)gates →*_HTTP_CLIENT_TYPEStuples(and the
TypeErrormessage names both accepted types when the extra is installed)except httpx.TimeoutException/except httpx.HTTPStatusError(×2 each) → both-hierarchytuples; likewise
StreamConsumedin_response.py, the timeout tuple in the assistantsstreaming helper, and
RequestNotReadin the Bedrock SigV4 body-signing path_build_request:url=coerce_httpx2_request_url(self._client, prepared_url)— httpx2'sbuild_requestrejects a classichttpx.URL(acceptsstrorhttpx2.URL), so httpx2clients get
str(url); classic clients are untouchedHTTPX2_DEFAULT_TIMEOUT, so a barehttpx2.Client()gets the SDK's 600s default rather than httpx2's 5s (parity with how abare
httpx.Client()is treated today)logging.getLogger("httpx2")joins the log-level setup;__init__.pygains only the twoexport lines (same place the
DefaultAioHttpClientexport lives)Verification
green on the 3.9 floor.
nox -s test-httpx2session on 3.10–3.14 —isinstance gates, both timeout footguns, URL coercion, status→SDK-error mapping, retries
(incl.
x-stainless-retry-count), Bedrock body-signing seam, graceful degradation withoutthe extra (subprocess-forced absence).
cherry-pickability.
Additional context & links
Why
httpxhas effectively stalled: no stable release since v0.28.1 (Dec 2024), and issues/discussions were closed off repo-wide in Feb 2026 (encode/httpx#3784). The ecosystem is
moving to Pydantic's maintained fork,
httpx2. For example,Starlette's test client now prefers
httpx2and warns on plainhttpx(Kludex/starlette#3291) with the author calling it "the least annoying path forward for every consumer." Keeping an unmaintained library on the live request path of every SDK consumer is the underlying concern; this PR exists to make the "what would supporting an alternative actually take" side of thatdiscussion concrete, not to insist on this particular answer.
Honest caveat: this is heavier than the
aiohttpextra, and whyopenai[aiohttp]is transport-shaped:DefaultAioHttpClientis still anhttpx.AsyncClient, it only swaps the network transport, so it sails through every existingisinstance/except httpx.*check untouched.httpx2is a separate library — its ownClient/AsyncClient, its own exceptionhierarchy (
httpx2.TimeoutExceptionis nothttpx.TimeoutException), its ownResponse.So an httpx2 client is not an
httpx.AsyncClientand does not raisehttpx.*. Supporting itrequires widening the SDK's type/exception checks to both backends. That cost is inherent —
not a design choice that can be avoided by mimicking the aiohttp shape.
Where this may really belong: a note on Stainless routing
From the outside, the httpx coupling doesn't appear to live in this SDK's spec-generated
code:
_base_client.pyand the other runtime modules carry no "generated from our OpenAPIspec" header and look near-identical across Stainless-generated SDKs (e.g.
anthropic-sdk-python, which also ships the sameaiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"]extra). How that runtime is actually factored and maintained is onlyvisible to the Stainless/OpenAI folks — apologies if this is mis-aimed — but two things seem
worth stating:
fix would sit at the Stainless level, where it could flow to every generated Python SDK at
once. If this PR ends up being useful mainly as a spec for that conversation, that's a
fine outcome.
persisting via a semantic three-way merge on regeneration, which suggests every
hand-modified line in a runtime file is a recurring merge surface. This PR tries to
minimise that surface: all httpx2 knowledge lives in one new leaf module, and runtime
files get only one-line substitutions that should be mechanical to express at the template
level — if that's the right place for them.
Things I'd want a maintainer's call on
httpx2requires 3.10+ (it dropped 3.9). The extra carries apython_version >= '3.10'marker, so it installs nothing on 3.9.respxdoesn't support httpx2(lundberg/respx#316), so the tests use
httpx2's own
MockTransportin standalone files (generatedtests/test_client.py/conftest.pyuntouched). httpx2 is absent from the 3.9-floored dev lock, so the dedicatednox -s test-httpx2session installs the extra on 3.10+ and runs the suite as required,not skippable. A 3.10+ CI job would want to call it.
cast_to=httpx.Response: with an httpx2 backend this returns a duck-compatiblehttpx2.Response(not anisinstanceofhttpx.Response);cast_to=httpx2.Responseisn'ta recognised sentinel. Kept as a documented limitation — happy to add a backend-neutral
sentinel if preferred.
structurally indistinguishable from "not customised" and gets the SDK default — same as
classic httpx today. Documented in the README caveats with the workaround.
httpx2shipspy.typed; theTYPE_CHECKINGaliases keep pyright-strict +mypy green on 3.9 (where httpx2 isn't installable), mirroring the aiohttp approach.
(Consider enabling HTTP2 by default #3476, feat: Enable HTTP/2 by default in httpx client #3477) circle the same underlying question as this PR — how much of the HTTP stack
should be swappable or configurable. An opt-in backend extra is one possible shape for
that; a more general pluggable-transport story (in the SDK or on the Stainless side) would
be another. Happy to rework toward whichever direction is preferred.
Disclosure: this implementation and write-up were produced with AI assistance; I have
reviewed, adjusted, and verified all of it myself to the best of my ability.