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.
|
This doesn't actually implement HTTP/2. httpx2 here is a distinct forked HTTP library, not HTTP/2 protocol support. It doesn't touch http2= anywhere in |
|
@amirhosseinghanipour I don't think this PR is about HTTP/2 but more so about replacing the abandoned httpx python package. See this issue: #3375 |
jbeckwith-oai
left a comment
There was a problem hiding this comment.
Requesting changes for three blocking compatibility/validation gaps:
-
src/openai/_base_client.py:607— convert timeout objects at the backend boundary. A barehttpx2.Clientis explicitly accepted, but the constructor replaces its default with the SDK's classichttpx.Timeoutand_build_requestforwards that foreign object unchanged.httpx2.build_requestthen produces timeout extensions whose four values are entirehttpx.Timeoutobjects rather than numeric seconds. Against a real transport, sync requests fail asAPIConnectionErrorcaused byTypeError: 'Timeout' object cannot be interpreted as an integer; async requests fail similarly (float + Timeout). The same failure affects the documentedOpenAI(timeout=httpx.Timeout(...), http_client=...)and per-request timeout paths. The MockTransport tests pass because they never consume the timeout extensions. Please coerce classichttpx.Timeout/DEFAULT_TIMEOUTtohttpx2.Timeout, analogous to the URL coercion, and cover bare clients plus client/per-request classic timeout values with a real-transport or extension-shape assertion for sync and async. -
src/openai/_base_client.py:487— keepx-stainless-read-timeoutnumeric for native httpx2 timeouts.DefaultHttpx2Clientcorrectly stores anhttpx2.Timeout, but thisisinstance(..., httpx.Timeout)check does not recognize it. The default advertised backend therefore sendsx-stainless-read-timeout: Timeout(connect=5.0, read=600, write=600, pool=600)instead of600. Please extract.readfor both timeout implementations and add a request-header assertion. -
noxfile.py:20— wire the new backend suite into CI. The session says the tests are exercised in CI, but.github/workflows/ci.ymlonly runs./scripts/test, which invokes normal pytest andtest-pydantic-v1; it never invokestest-httpx2. The repository's Python 3.9 environment cannot install the extra, sotests/test_httpx2_client.pyimport-skips in the ordinary job. Please add a Python >=3.10 CI job/step that runs this nox session. This is especially important because the current all-MockTransport suite misses the real timeout failure above.
Validation performed at exact head af3d568cc9b157c172b37e519e94aa2ea42ebeff: focused suite passed with both minimum httpx2==2.5.0 and current 2.9.1 (36 passed, 1 skipped each); Ruff and format checks passed; direct request-extension inspection and real local sync/async transports reproduced the timeout failure; the default wrapper reproduced the malformed x-stainless-read-timeout header. Hosted checks are not present on the PR.
|
Justin, I could work on that and also fix the conflicts. But before that, I wonder what your and or openai's stance on #3375 (comment) is - ie will something come from stainless and/or should something more flexible with @runtime_checkable Protocol be considered? |
|
Hold on. #3524 seams to have applied most of this already, was created after this PR, and is merged. |
|
Hi @peterlundberg — thank you for taking the time to put this together and for calling attention to HTTPX2 support. I double-checked the current Since the behavior you were aiming for is already covered, I’m going to close this PR to keep the queue tidy. We really appreciate the contribution and the care you put into improving the SDK! |
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.