Skip to content

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

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

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

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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant