Skip to content

feat(client): opt-in httpx2 backend via an openai[httpx2] extra - #3479

Closed
peterlundberg wants to merge 10 commits into
openai:mainfrom
peterlundberg:feat/httpx2-extra
Closed

feat(client): opt-in httpx2 backend via an openai[httpx2] extra#3479
peterlundberg wants to merge 10 commits into
openai:mainfrom
peterlundberg:feat/httpx2-extra

Conversation

@peterlundberg

@peterlundberg peterlundberg commented Jul 9, 2026

Copy link
Copy Markdown
  • I understand that this repository is auto-generated and my pull request may not be merged

Changes being requested

Draft, for reference and discussion — a concrete example of what supporting httpx2
would actually take, prompted by #3375 (consider migrating from httpx to httpx2). Opt-in
only; zero behaviour change unless the extra is installed and the client is explicitly
constructed with an httpx2 client. I'm not attached to this shape: happy to rework it
toward a different approach, or for this to simply be closed if the team lands elsewhere —
it will have served its purpose either way.

An opt-in openai[httpx2] extra, mirroring the ergonomics of the accepted openai[aiohttp]
extra:

pip install openai[httpx2]
from openai import OpenAI, DefaultHttpx2Client

client = OpenAI(http_client=DefaultHttpx2Client())

DefaultAsyncHttpx2Client mirrors it for AsyncOpenAI.

Design

_httpx2_compat.py (new leaf module, guarded try: 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, and
Default{,Async}Httpx2Client (with RuntimeError stubs and TYPE_CHECKING aliases to
httpx.* when the extra is absent — the same pattern as DefaultAioHttpClient).

Other files then need only substitutions:

  • two isinstance(http_client, httpx.{,Async}Client) gates → *_HTTP_CLIENT_TYPES tuples
    (and the TypeError message names both accepted types when the extra is installed)
  • except httpx.TimeoutException / except httpx.HTTPStatusError (×2 each) → both-hierarchy
    tuples; likewise StreamConsumed in _response.py, the timeout tuple in the assistants
    streaming helper, and RequestNotRead in the Bedrock SigV4 body-signing path
  • _build_request: url=coerce_httpx2_request_url(self._client, prepared_url) — httpx2's
    build_request rejects a classic httpx.URL (accepts str or httpx2.URL), so httpx2
    clients get str(url); classic clients are untouched
  • the default-timeout structural compare also excludes HTTPX2_DEFAULT_TIMEOUT, so a bare
    httpx2.Client() gets the SDK's 600s default rather than httpx2's 5s (parity with how a
    bare httpx.Client() is treated today)
  • logging.getLogger("httpx2") joins the log-level setup; __init__.py gains only the two
    export lines (same place the DefaultAioHttpClient export lives)

Verification

  • Full existing suite unchanged and green (incl. pydantic-v1 session); pyright-strict + mypy
    green on the 3.9 floor.
  • httpx2 suite: 34 tests via a dedicated nox -s test-httpx2 session 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 without
    the extra (subprocess-forced absence).
  • Commits are split per concern (deps → feat → tests → docs → CI → review fixes) for
    cherry-pickability.

Additional context & links

Why

httpx has 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 httpx2 and warns on plain httpx(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 that
discussion concrete, not to insist on this particular answer.

Honest caveat: this is heavier than the aiohttp extra, and why

openai[aiohttp] is transport-shaped: DefaultAioHttpClient is still an
httpx.AsyncClient, it only swaps the network transport, so it sails through every existing
isinstance/except httpx.* check untouched.

httpx2 is a separate library — its own Client/AsyncClient, its own exception
hierarchy (httpx2.TimeoutException is not httpx.TimeoutException), its own Response.
So an httpx2 client is not an httpx.AsyncClient and does not raise httpx.*. Supporting it
requires 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.py and the other runtime modules carry no "generated from our OpenAPI
spec" header and look near-identical across Stainless-generated SDKs (e.g.
anthropic-sdk-python, which also ships the same aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] extra). How that runtime is actually factored and maintained is only
visible to the Stainless/OpenAI folks — apologies if this is mis-aimed — but two things seem
worth stating:

  1. This may not be openai-python's decision alone. If that runtime is shared, the durable
    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.
  2. The diff is shaped with that in mind. Stainless's public docs describe custom code as
    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

  • Python support: httpx2 requires 3.10+ (it dropped 3.9). The extra carries a
    python_version >= '3.10' marker, so it installs nothing on 3.9.
  • Testing: respx doesn't support httpx2
    (lundberg/respx#316), so the tests use
    httpx2's own MockTransport in standalone files (generated tests/test_client.py/
    conftest.py untouched). httpx2 is absent from the 3.9-floored dev lock, so the dedicated
    nox -s test-httpx2 session 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-compatible
    httpx2.Response (not an isinstance of httpx.Response); cast_to=httpx2.Response isn't
    a recognised sentinel. Kept as a documented limitation — happy to add a backend-neutral
    sentinel if preferred.
  • Timeout parity footgun: a client whose timeout equals httpx2's own default (5s) is
    structurally indistinguishable from "not customised" and gets the SDK default — same as
    classic httpx today. Documented in the README caveats with the workaround.
  • Typing: httpx2 ships py.typed; the TYPE_CHECKING aliases keep pyright-strict +
    mypy green on 3.9 (where httpx2 isn't installable), mirroring the aiohttp approach.
  • Adjacent asks: the TCP-keepalive requests ([Bug] Non-streaming calls silently hang forever behind NAT — default httpx transport has no TCP keepalive #3269, fix: add TCP keepalive to default httpx transport to prevent NAT hangs #3368) and the HTTP/2-default asks
    (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.

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.
@peterlundberg
peterlundberg requested a review from a team as a code owner July 9, 2026 20:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +98 to +99
SYNC_HTTP_CLIENT_TYPES = (httpx.Client, _h2.Client)
ASYNC_HTTP_CLIENT_TYPES = (httpx.AsyncClient, _h2.AsyncClient)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@peterlundberg peterlundberg Jul 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

peterlundberg and others added 2 commits July 9, 2026 22:55
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.
@amirhosseinghanipour

Copy link
Copy Markdown

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 _base_client.py. This looks like it's solving a different problem than #3476 asked for, and the scope is much larger than the issue calls for?

@mve

mve commented Jul 29, 2026

Copy link
Copy Markdown

@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 jbeckwith-oai 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.

Requesting changes for three blocking compatibility/validation gaps:

  1. src/openai/_base_client.py:607 — convert timeout objects at the backend boundary. A bare httpx2.Client is explicitly accepted, but the constructor replaces its default with the SDK's classic httpx.Timeout and _build_request forwards that foreign object unchanged. httpx2.build_request then produces timeout extensions whose four values are entire httpx.Timeout objects rather than numeric seconds. Against a real transport, sync requests fail as APIConnectionError caused by TypeError: 'Timeout' object cannot be interpreted as an integer; async requests fail similarly (float + Timeout). The same failure affects the documented OpenAI(timeout=httpx.Timeout(...), http_client=...) and per-request timeout paths. The MockTransport tests pass because they never consume the timeout extensions. Please coerce classic httpx.Timeout/DEFAULT_TIMEOUT to httpx2.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.

  2. src/openai/_base_client.py:487 — keep x-stainless-read-timeout numeric for native httpx2 timeouts. DefaultHttpx2Client correctly stores an httpx2.Timeout, but this isinstance(..., httpx.Timeout) check does not recognize it. The default advertised backend therefore sends x-stainless-read-timeout: Timeout(connect=5.0, read=600, write=600, pool=600) instead of 600. Please extract .read for both timeout implementations and add a request-header assertion.

  3. noxfile.py:20 — wire the new backend suite into CI. The session says the tests are exercised in CI, but .github/workflows/ci.yml only runs ./scripts/test, which invokes normal pytest and test-pydantic-v1; it never invokes test-httpx2. The repository's Python 3.9 environment cannot install the extra, so tests/test_httpx2_client.py import-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.

@peterlundberg

Copy link
Copy Markdown
Author

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?

@peterlundberg

peterlundberg commented Aug 4, 2026

Copy link
Copy Markdown
Author

Hold on. #3524 seams to have applied most of this already, was created after this PR, and is merged.
Do you want anything from this PR or should I close it? Anything else you would want help on?

Copy link
Copy Markdown
Contributor

Hi @peterlundberg — thank you for taking the time to put this together and for calling attention to HTTPX2 support. I double-checked the current main branch, and the underlying change is already available in ae8c3d5d: the SDK now uses HTTPX2 as its supported default backend throughout the sync and async clients, rather than requiring an opt-in extra.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants