diff --git a/README.md b/README.md index df46c6b690..038d22feb0 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,73 @@ async def main() -> None: asyncio.run(main()) ``` +### With httpx2 + +`httpx2` is Pydantic's maintained fork of `httpx`. You may use it as the HTTP backend for either client. + +You can enable this by installing `httpx2`: + +```sh +# install from PyPI +pip install openai[httpx2] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultHttpx2Client()`: + +```python +import os +from openai import OpenAI, DefaultHttpx2Client + +client = OpenAI( + api_key=os.environ.get("OPENAI_API_KEY"), # This is the default and can be omitted + http_client=DefaultHttpx2Client(), +) + +chat_completion = client.chat.completions.create( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-5.5", +) +``` + +Or with the async client, using `DefaultAsyncHttpx2Client()`: + +```python +import os +import asyncio +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + + +async def main() -> None: + async with AsyncOpenAI( + api_key=os.environ.get("OPENAI_API_KEY"), # This is the default and can be omitted + http_client=DefaultAsyncHttpx2Client(), + ) as client: + chat_completion = await client.chat.completions.create( + messages=[ + { + "role": "user", + "content": "Say this is a test", + } + ], + model="gpt-5.5", + ) + + +asyncio.run(main()) +``` + +A few things to be aware of when using `httpx2`: + +- `httpx2` requires Python 3.10 or newer (it dropped support for 3.9), so the `httpx2` extra installs nothing on Python 3.9. +- When streaming raw responses, the exceptions raised come from `httpx2` (e.g. `httpx2.StreamConsumed`), not from `httpx`. +- `cast_to=httpx.Response` still works but returns a duck-compatible `httpx2.Response`, which is not an `isinstance` of `httpx.Response`. Passing `cast_to=httpx2.Response` is not supported. +- As with classic `httpx`, a client whose timeout equals the library's own default (`httpx2.Timeout(5.0)`) is treated as "not customised" and the SDK's default of 10 minutes is used instead. To actually get a 5 second timeout, pass `timeout=5.0` to the `OpenAI(...)` constructor rather than to the http client. + ## Streaming responses We provide support for streaming responses using Server Side Events (SSE). diff --git a/examples/httpx2_client.py b/examples/httpx2_client.py new file mode 100644 index 0000000000..81e591d64c --- /dev/null +++ b/examples/httpx2_client.py @@ -0,0 +1,45 @@ +#!/usr/bin/env -S poetry run python + +import asyncio + +from openai import OpenAI, AsyncOpenAI, DefaultHttpx2Client, DefaultAsyncHttpx2Client + +# gets API Key from environment variable OPENAI_API_KEY +client = OpenAI(http_client=DefaultHttpx2Client()) + +# Non-streaming, synchronous client backed by httpx2: +print("----- standard request -----") +completion = client.chat.completions.create( + model="gpt-5.5", + messages=[ + { + "role": "user", + "content": "Say this is a test", + }, + ], +) +print(completion.choices[0].message.content) + + +async def main() -> None: + async with AsyncOpenAI(http_client=DefaultAsyncHttpx2Client()) as async_client: + print("----- async streaming request -----") + stream = await async_client.chat.completions.create( + model="gpt-5.5", + messages=[ + { + "role": "user", + "content": "How do I output all files in a directory using Python?", + }, + ], + stream=True, + ) + async for chunk in stream: + if not chunk.choices: + continue + + print(chunk.choices[0].delta.content, end="") + print() + + +asyncio.run(main()) diff --git a/noxfile.py b/noxfile.py index 53bca7ff2a..950d1acc6b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,3 +7,24 @@ def test_pydantic_v1(session: nox.Session) -> None: session.install("pydantic<2") session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) + + +# httpx2 is a Python 3.10+ optional extra and is absent from the 3.9-floored dev lock, +# so its tests are skipped by the default session. This session installs a real httpx2 +# on a supported interpreter and runs the httpx2 suite so the widened exception tuples, +# timeout handling and URL coercion are actually exercised in CI. +# +# We install the package (with the httpx2 extra) plus the test tooling unpinned rather +# than `requirements-dev.lock`: that lock is resolved at the 3.9 floor and some pins +# (e.g. backports-asyncio-runner) refuse to install on Python 3.12+. +@nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"], name="test-httpx2") +def test_httpx2(session: nox.Session) -> None: + session.install("-e", ".[httpx2]") + session.install("pytest", "pytest-asyncio", "pytest-xdist") + + session.run( + "pytest", + "tests/test_httpx2_client.py", + "tests/test_httpx2_not_installed.py", + *session.posargs, + ) diff --git a/pyproject.toml b/pyproject.toml index 423289c26e..1506c56e41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ Repository = "https://github.com/openai/openai-python" [project.optional-dependencies] aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] +httpx2 = ["httpx2>=2.5.0,<3; python_version >= '3.10'"] realtime = ["websockets >= 13, < 16"] datalib = ["numpy >= 1", "pandas >= 1.2.3", "pandas-stubs >= 1.1.0.11"] voice_helpers = ["sounddevice>=0.5.1", "numpy>=2.0.2"] diff --git a/src/openai/__init__.py b/src/openai/__init__.py index a3f3237c38..4ea88fce3d 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -36,7 +36,13 @@ ContentFilterFinishReasonError, WebSocketConnectionClosedError, ) -from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient +from ._base_client import ( + DefaultHttpxClient, + DefaultHttpx2Client, + DefaultAioHttpClient, + DefaultAsyncHttpxClient, + DefaultAsyncHttpx2Client, +) from ._utils._logs import setup_logging as _setup_logging from ._legacy_response import HttpxBinaryResponseContent as HttpxBinaryResponseContent from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides @@ -89,6 +95,8 @@ "DefaultHttpxClient", "DefaultAsyncHttpxClient", "DefaultAioHttpClient", + "DefaultHttpx2Client", + "DefaultAsyncHttpx2Client", "ReconnectingEvent", "ReconnectingOverrides", "WebSocketQueueFullError", diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 216b36aabd..dfbb75c4dd 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -88,6 +88,18 @@ APIResponseValidationError, ) from ._utils._json import openapi_dumps +from ._httpx2_compat import ( + HTTP_STATUS_ERRORS, + TIMEOUT_EXCEPTIONS, + HTTPX2_DEFAULT_TIMEOUT, + SYNC_HTTP_CLIENT_NAMES, + SYNC_HTTP_CLIENT_TYPES, + ASYNC_HTTP_CLIENT_NAMES, + ASYNC_HTTP_CLIENT_TYPES, + DefaultHttpx2Client as DefaultHttpx2Client, + DefaultAsyncHttpx2Client as DefaultAsyncHttpx2Client, + coerce_httpx2_request_url, +) from ._legacy_response import LegacyAPIResponse log: logging.Logger = logging.getLogger(__name__) @@ -594,7 +606,7 @@ def _build_request( headers=headers, timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, method=options.method, - url=prepared_url, + url=coerce_httpx2_request_url(self._client, prepared_url), # the `Query` type that we use is incompatible with qs' # `Params` type as it needs to be typed as `Mapping[str, object]` # so that passing a `TypedDict` doesn't cause an error. @@ -886,14 +898,20 @@ def __init__( # where they've explicitly set the timeout to match the default timeout # as this check is structural, meaning that we'll think they didn't # pass in a timeout and will ignore it - if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + if ( + http_client + and http_client.timeout != HTTPX_DEFAULT_TIMEOUT + # a bare httpx2 client reports httpx2's own default (5s), which is `!=` the + # classic default across the class boundary; treat it as "not customised" too + and http_client.timeout != HTTPX2_DEFAULT_TIMEOUT + ): timeout = http_client.timeout else: timeout = DEFAULT_TIMEOUT - if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + if http_client is not None and not isinstance(http_client, SYNC_HTTP_CLIENT_TYPES): # pyright: ignore[reportUnnecessaryIsInstance] raise TypeError( - f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + f"Invalid `http_client` argument; Expected an instance of {SYNC_HTTP_CLIENT_NAMES} but got {type(http_client)}" ) super().__init__( @@ -1039,7 +1057,7 @@ def request( stream=stream or self._should_stream_response_body(request=request), **kwargs, ) - except httpx.TimeoutException as err: + except TIMEOUT_EXCEPTIONS as err: log.debug("Encountered httpx.TimeoutException", exc_info=True) if remaining_retries > 0: @@ -1083,7 +1101,7 @@ def request( try: response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + except HTTP_STATUS_ERRORS as err: # thrown on 4xx and 5xx status code log.debug("Encountered httpx.HTTPStatusError", exc_info=True) if remaining_retries > 0 and self._should_retry(err.response): @@ -1496,14 +1514,20 @@ def __init__( # where they've explicitly set the timeout to match the default timeout # as this check is structural, meaning that we'll think they didn't # pass in a timeout and will ignore it - if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + if ( + http_client + and http_client.timeout != HTTPX_DEFAULT_TIMEOUT + # a bare httpx2 client reports httpx2's own default (5s), which is `!=` the + # classic default across the class boundary; treat it as "not customised" too + and http_client.timeout != HTTPX2_DEFAULT_TIMEOUT + ): timeout = http_client.timeout else: timeout = DEFAULT_TIMEOUT - if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + if http_client is not None and not isinstance(http_client, ASYNC_HTTP_CLIENT_TYPES): # pyright: ignore[reportUnnecessaryIsInstance] raise TypeError( - f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + f"Invalid `http_client` argument; Expected an instance of {ASYNC_HTTP_CLIENT_NAMES} but got {type(http_client)}" ) super().__init__( @@ -1650,7 +1674,7 @@ async def request( stream=stream or self._should_stream_response_body(request=request), **kwargs, ) - except httpx.TimeoutException as err: + except TIMEOUT_EXCEPTIONS as err: log.debug("Encountered httpx.TimeoutException", exc_info=True) if remaining_retries > 0: @@ -1694,7 +1718,7 @@ async def request( try: response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + except HTTP_STATUS_ERRORS as err: # thrown on 4xx and 5xx status code log.debug("Encountered httpx.HTTPStatusError", exc_info=True) if remaining_retries > 0 and self._should_retry(err.response): diff --git a/src/openai/_client.py b/src/openai/_client.py index 66d03b23dd..e05e0480c9 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -39,6 +39,7 @@ SyncAPIClient, AsyncAPIClient, ) +from ._httpx2_compat import noop_auth_for if TYPE_CHECKING: from .resources import ( @@ -560,7 +561,7 @@ def _prepare_request(self, request: httpx.Request) -> None: @override def _custom_auth(self, security: SecurityOptions) -> httpx.Auth | None: if self._provider_runtime is not None: - return httpx.Auth() + return noop_auth_for(self._client) return super()._custom_auth(security) @@ -1167,7 +1168,7 @@ async def _prepare_request(self, request: httpx.Request) -> None: @override def custom_auth(self) -> httpx.Auth | None: if self._provider_runtime is not None: - return httpx.Auth() + return noop_auth_for(self._client) return super().custom_auth diff --git a/src/openai/_httpx2_compat.py b/src/openai/_httpx2_compat.py new file mode 100644 index 0000000000..8491092ce7 --- /dev/null +++ b/src/openai/_httpx2_compat.py @@ -0,0 +1,165 @@ +# Hand-written module (NOT generated by Stainless). +# +# Centralises every piece of httpx2 knowledge behind the optional `httpx2` extra so +# that `_base_client.py` needs only tiny, codegen-friendly substitutions. httpx2 is +# Pydantic's maintained fork of httpx; it is a *separate* library (its own client, +# exception hierarchy and Response), so classic-httpx `isinstance`/`except` checks do +# not see it and must be widened to the tuples defined here. +# +# The module imports cleanly whether or not httpx2 is installed. When it is absent, +# the type tuples collapse to the classic-httpx entries (zero behaviour change) and +# `DefaultHttpx2Client` becomes a stub that raises a helpful error, mirroring the +# accepted `DefaultAioHttpClient` pattern. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import httpx + +from ._constants import DEFAULT_TIMEOUT, DEFAULT_CONNECTION_LIMITS + +try: + import httpx2 # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] +except ImportError: + httpx2 = None # type: ignore[assignment] + + +__all__ = [ + "DefaultHttpx2Client", + "DefaultAsyncHttpx2Client", + "SYNC_HTTP_CLIENT_TYPES", + "ASYNC_HTTP_CLIENT_TYPES", + "SYNC_HTTP_CLIENT_NAMES", + "ASYNC_HTTP_CLIENT_NAMES", + "TIMEOUT_EXCEPTIONS", + "HTTP_STATUS_ERRORS", + "STREAM_CONSUMED_ERRORS", + "REQUEST_NOT_READ_ERRORS", + "HTTPX2_DEFAULT_TIMEOUT", + "coerce_httpx2_request_url", + "noop_auth_for", +] + +_NOT_INSTALLED_MESSAGE = "To use the httpx2 client you must have installed the package with the `httpx2` extra" + + +if TYPE_CHECKING: + # What the type checker (targeting py3.9, where httpx2 is not installable) sees. + # The runtime values below additionally include the httpx2 equivalents when the + # extra is present; the classic-httpx types here keep `err.response` et al. resolving + # to httpx.* at the `except`/`isinstance` call sites in `_base_client.py`. + SYNC_HTTP_CLIENT_TYPES: tuple[type[httpx.Client], ...] = (httpx.Client,) + ASYNC_HTTP_CLIENT_TYPES: tuple[type[httpx.AsyncClient], ...] = (httpx.AsyncClient,) + TIMEOUT_EXCEPTIONS: tuple[type[httpx.TimeoutException], ...] = (httpx.TimeoutException,) + HTTP_STATUS_ERRORS: tuple[type[httpx.HTTPStatusError], ...] = (httpx.HTTPStatusError,) + STREAM_CONSUMED_ERRORS: tuple[type[httpx.StreamConsumed], ...] = (httpx.StreamConsumed,) + REQUEST_NOT_READ_ERRORS: tuple[type[httpx.RequestNotRead], ...] = (httpx.RequestNotRead,) + + # Human-readable names of the accepted client types, for `TypeError` messages in + # `_base_client.py` (mentions httpx2 only when the extra is actually importable). + SYNC_HTTP_CLIENT_NAMES: str = "`httpx.Client`" + ASYNC_HTTP_CLIENT_NAMES: str = "`httpx.AsyncClient`" + + # Sentinel for the cross-class default-timeout comparison in `_base_client.py` + # (a bare httpx2 client reports httpx2's 5s default, which is `!=` the classic + # default and must not be mistaken for a user-supplied timeout). `None` when the + # extra is absent, so the extra comparison is a harmless no-op. + HTTPX2_DEFAULT_TIMEOUT: httpx.Timeout | None = None + + _HTTPX2_CLIENT_TYPES: tuple[type[Any], ...] = () + _HTTPX2_AUTH_CLASS: type[Any] | None = None + + # Aliased to httpx.* for type checking, exactly like `DefaultAioHttpClient`. The + # real runtime classes are defined below. + DefaultHttpx2Client = httpx.Client + DefaultAsyncHttpx2Client = httpx.AsyncClient +elif httpx2 is None: + SYNC_HTTP_CLIENT_TYPES = (httpx.Client,) + ASYNC_HTTP_CLIENT_TYPES = (httpx.AsyncClient,) + TIMEOUT_EXCEPTIONS = (httpx.TimeoutException,) + HTTP_STATUS_ERRORS = (httpx.HTTPStatusError,) + STREAM_CONSUMED_ERRORS = (httpx.StreamConsumed,) + REQUEST_NOT_READ_ERRORS = (httpx.RequestNotRead,) + SYNC_HTTP_CLIENT_NAMES = "`httpx.Client`" + ASYNC_HTTP_CLIENT_NAMES = "`httpx.AsyncClient`" + HTTPX2_DEFAULT_TIMEOUT = None + _HTTPX2_CLIENT_TYPES = () + _HTTPX2_AUTH_CLASS = None + + class DefaultHttpx2Client(httpx.Client): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError(_NOT_INSTALLED_MESSAGE) + + class DefaultAsyncHttpx2Client(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError(_NOT_INSTALLED_MESSAGE) +else: + # Funnel every httpx2 attribute access through a single `Any` alias so the module + # type-checks with httpx2 absent (its types are unresolved on the py3.9 lint env). + _h2: Any = httpx2 + + SYNC_HTTP_CLIENT_TYPES = (httpx.Client, _h2.Client) + ASYNC_HTTP_CLIENT_TYPES = (httpx.AsyncClient, _h2.AsyncClient) + TIMEOUT_EXCEPTIONS = (httpx.TimeoutException, _h2.TimeoutException) + HTTP_STATUS_ERRORS = (httpx.HTTPStatusError, _h2.HTTPStatusError) + STREAM_CONSUMED_ERRORS = (httpx.StreamConsumed, _h2.StreamConsumed) + REQUEST_NOT_READ_ERRORS = (httpx.RequestNotRead, _h2.RequestNotRead) + SYNC_HTTP_CLIENT_NAMES = "`httpx.Client` or `httpx2.Client`" + ASYNC_HTTP_CLIENT_NAMES = "`httpx.AsyncClient` or `httpx2.AsyncClient`" + _HTTPX2_CLIENT_TYPES = (_h2.Client, _h2.AsyncClient) + _HTTPX2_AUTH_CLASS = _h2.Auth + + try: + HTTPX2_DEFAULT_TIMEOUT = _h2._config.DEFAULT_TIMEOUT_CONFIG + except AttributeError: # pragma: no cover - defensive, tracks httpx's own fallback + HTTPX2_DEFAULT_TIMEOUT = _h2.Timeout(5.0) + + def _httpx2_timeout() -> Any: + # Rebuild the SDK defaults natively: httpx2's client rejects a classic + # `httpx.Timeout` at construction (though `build_request` accepts one). + return _h2.Timeout( + connect=DEFAULT_TIMEOUT.connect, + read=DEFAULT_TIMEOUT.read, + write=DEFAULT_TIMEOUT.write, + pool=DEFAULT_TIMEOUT.pool, + ) + + def _httpx2_limits() -> Any: + return _h2.Limits( + max_connections=DEFAULT_CONNECTION_LIMITS.max_connections, + max_keepalive_connections=DEFAULT_CONNECTION_LIMITS.max_keepalive_connections, + ) + + class DefaultHttpx2Client(_h2.Client): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", _httpx2_timeout()) + kwargs.setdefault("limits", _httpx2_limits()) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + class DefaultAsyncHttpx2Client(_h2.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", _httpx2_timeout()) + kwargs.setdefault("limits", _httpx2_limits()) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +def coerce_httpx2_request_url(client: httpx.Client | httpx.AsyncClient, url: httpx.URL) -> httpx.URL | str: + """httpx2's ``build_request`` rejects a classic ``httpx.URL`` (``Expected str or + httpx2.URL``), so hand it a ``str`` when the underlying client is an httpx2 one. + Classic httpx clients keep the ``httpx.URL`` unchanged (no behaviour change).""" + if _HTTPX2_CLIENT_TYPES and isinstance(client, _HTTPX2_CLIENT_TYPES): + return str(url) + return url + + +def noop_auth_for(client: httpx.Client | httpx.AsyncClient) -> httpx.Auth: + """A no-op ``Auth`` matched to the client's backend. The SDK passes a bare + ``httpx.Auth()`` to ``send()`` for provider-managed clients (the real auth headers + are applied by the provider's request hooks), but httpx2's ``send`` rejects a + classic ``httpx.Auth`` instance (``Invalid "auth" argument``), so httpx2 clients + must be handed an ``httpx2.Auth()`` instead.""" + if _HTTPX2_AUTH_CLASS is not None and isinstance(client, _HTTPX2_CLIENT_TYPES): + return cast("httpx.Auth", _HTTPX2_AUTH_CLASS()) + return httpx.Auth() diff --git a/src/openai/_response.py b/src/openai/_response.py index f286d38e6c..3ad4618b0f 100644 --- a/src/openai/_response.py +++ b/src/openai/_response.py @@ -30,6 +30,7 @@ from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type from ._exceptions import OpenAIError, APIResponseValidationError +from ._httpx2_compat import STREAM_CONSUMED_ERRORS if TYPE_CHECKING: from ._models import FinalRequestOptions @@ -337,7 +338,7 @@ def read(self) -> bytes: """Read and return the binary response content.""" try: return self.http_response.read() - except httpx.StreamConsumed as exc: + except STREAM_CONSUMED_ERRORS as exc: # The default error raised by httpx isn't very # helpful in our case so we re-raise it with # a different error message. @@ -444,7 +445,7 @@ async def read(self) -> bytes: """Read and return the binary response content.""" try: return await self.http_response.aread() - except httpx.StreamConsumed as exc: + except STREAM_CONSUMED_ERRORS as exc: # the default error raised by httpx isn't very # helpful in our case so we re-raise it with # a different error message diff --git a/src/openai/_utils/_logs.py b/src/openai/_utils/_logs.py index eaffa5ec7a..2482bd4779 100644 --- a/src/openai/_utils/_logs.py +++ b/src/openai/_utils/_logs.py @@ -6,6 +6,7 @@ logger: logging.Logger = logging.getLogger("openai") httpx_logger: logging.Logger = logging.getLogger("httpx") +httpx2_logger: logging.Logger = logging.getLogger("httpx2") SENSITIVE_HEADERS = {"api-key", "authorization", "x-amz-security-token"} @@ -25,10 +26,12 @@ def setup_logging() -> None: _basic_config() logger.setLevel(logging.DEBUG) httpx_logger.setLevel(logging.DEBUG) + httpx2_logger.setLevel(logging.DEBUG) elif env == "info": _basic_config() logger.setLevel(logging.INFO) httpx_logger.setLevel(logging.INFO) + httpx2_logger.setLevel(logging.INFO) class SensitiveHeadersFilter(logging.Filter): diff --git a/src/openai/lib/streaming/_assistants.py b/src/openai/lib/streaming/_assistants.py index 6efb3ca3f1..47198a7e5c 100644 --- a/src/openai/lib/streaming/_assistants.py +++ b/src/openai/lib/streaming/_assistants.py @@ -5,13 +5,12 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar, Callable, Iterable, Iterator, cast from typing_extensions import Awaitable, AsyncIterable, AsyncIterator, assert_never -import httpx - from ..._utils import is_dict, is_list, consume_sync_iterator, consume_async_iterator from ..._compat import model_dump from ..._models import construct_type from ..._streaming import Stream, AsyncStream from ...types.beta import AssistantStreamEvent +from ..._httpx2_compat import TIMEOUT_EXCEPTIONS from ...types.beta.threads import ( Run, Text, @@ -24,6 +23,10 @@ ) from ...types.beta.threads.runs import RunStep, ToolCall, RunStepDelta, ToolCallDelta +# httpx2's timeout exception is a distinct class from httpx's, so widen the catch to +# cover both backends alongside asyncio's own timeout. +_TIMEOUT_ERRORS: tuple[type[Exception], ...] = (*TIMEOUT_EXCEPTIONS, asyncio.TimeoutError) + class AssistantEventHandler: text_deltas: Iterable[str] @@ -407,7 +410,7 @@ def __stream__(self) -> Iterator[AssistantStreamEvent]: self._emit_sse_event(event) yield event - except (httpx.TimeoutException, asyncio.TimeoutError) as exc: + except _TIMEOUT_ERRORS as exc: self.on_timeout() self.on_exception(exc) raise @@ -839,7 +842,7 @@ async def __stream__(self) -> AsyncIterator[AssistantStreamEvent]: await self._emit_sse_event(event) yield event - except (httpx.TimeoutException, asyncio.TimeoutError) as exc: + except _TIMEOUT_ERRORS as exc: await self.on_timeout() await self.on_exception(exc) raise diff --git a/src/openai/providers/bedrock.py b/src/openai/providers/bedrock.py index 5cee093bfd..aa38aa51d8 100644 --- a/src/openai/providers/bedrock.py +++ b/src/openai/providers/bedrock.py @@ -13,6 +13,7 @@ from .._models import FinalRequestOptions from .._provider import _Provider, _create_provider, _ProviderRuntime from .._exceptions import OpenAIError +from .._httpx2_compat import REQUEST_NOT_READ_ERRORS from ..lib._bedrock_auth import ( BedrockAwsAuth, BedrockAwsAuthConfig, @@ -50,7 +51,7 @@ def _same_origin(left: httpx.URL, right: httpx.URL) -> bool: def _body_for_signing(request: httpx.Request) -> bytes: try: return request.content - except httpx.RequestNotRead as exc: + except REQUEST_NOT_READ_ERRORS as exc: raise OpenAIError( "Bedrock SigV4 authentication requires a replayable request body. " "Buffer the body before sending or use bearer authentication." diff --git a/tests/test_httpx2_client.py b/tests/test_httpx2_client.py new file mode 100644 index 0000000000..8f5533f7e5 --- /dev/null +++ b/tests/test_httpx2_client.py @@ -0,0 +1,396 @@ +"""Tests for the opt-in httpx2 backend (`openai._httpx2_compat`). + +These exercise the real `httpx2` library end-to-end, via `httpx2.MockTransport`, +against the openai client's request/response pipeline: + + * the isinstance gate that accepts/rejects httpx2 clients on `OpenAI`/`AsyncOpenAI` + * the 600s timeout footgun fix (httpx2's own bare-client default is 5s) + * the classic-httpx `URL` -> `str` coercion needed at request-build time + * the widened exception tuples that map httpx2's *independent* exception + hierarchy onto the SDK's error classes (status errors, timeouts, connect errors) + * retries (and the `x-stainless-retry-count` header) over an httpx2 transport + * the Bedrock SigV4 body-signing seam, where httpx2's `RequestNotRead` must map + onto the SDK's friendly `OpenAIError` + * provider-managed auth over an httpx2 backend: the no-op `Auth` handed to `send()` + must come from the client's own backend (httpx2 rejects a classic `httpx.Auth`) + +httpx2 is an opt-in extra that isn't installable on Python 3.9, so the whole module +is skipped cleanly when it isn't present. +""" + +from __future__ import annotations + +from typing import Any, Callable +from unittest import mock + +import httpx +import pytest + +httpx2 = pytest.importorskip("httpx2") + +from openai import ( + OpenAI, + AsyncOpenAI, + OpenAIError, + NotFoundError, + APIStatusError, + RateLimitError, + APITimeoutError, + BadRequestError, + APIConnectionError, + DefaultHttpx2Client, + InternalServerError, + DefaultAsyncHttpx2Client, +) + +api_key = "sk-test" + +MODEL_JSON = {"id": "gpt-4", "object": "model", "created": 0, "owned_by": "openai"} + +Handler = Callable[[Any], Any] + + +def _error_body(message: str = "boom") -> dict[str, Any]: + return {"error": {"message": message, "type": "invalid_request_error", "code": None, "param": None}} + + +def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.01 + + +def _assert_sdk_default_timeout(timeout: Any) -> None: + # The timeout is duck-typed: an httpx2.Timeout when the SDK adopts the httpx2 + # client's own value, or a classic httpx.Timeout when it substitutes DEFAULT_TIMEOUT + # (the bare-client path). Both expose .read/.connect; typing the param as Any + # sidesteps the `float | Timeout | None` union member access under pyright-strict. + assert timeout.read == 600 + assert timeout.connect == 5 + + +def _model_handler(request: Any) -> Any: + """Handler shared by sync + async tests: httpx2.MockTransport allows a plain + (non-async) handler for both `httpx2.Client` and `httpx2.AsyncClient`.""" + if "missing" in request.url.path: + return httpx2.Response(404, json=_error_body("no such model")) + return httpx2.Response(200, json=MODEL_JSON) + + +def _status_handler(status_code: int) -> Handler: + def handler(_request: Any) -> Any: + return httpx2.Response(status_code, json=_error_body()) + + return handler + + +def _sync_client(handler: Handler, **kwargs: Any) -> OpenAI: + return OpenAI( + api_key=api_key, + http_client=DefaultHttpx2Client(transport=httpx2.MockTransport(handler)), + **kwargs, + ) + + +def _async_client(handler: Handler, **kwargs: Any) -> AsyncOpenAI: + return AsyncOpenAI( + api_key=api_key, + http_client=DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(handler)), + **kwargs, + ) + + +class TestConstructionAndGate: + def test_default_httpx2_client_constructs(self) -> None: + with DefaultHttpx2Client(transport=httpx2.MockTransport(_model_handler)) as client: + assert isinstance(client, httpx2.Client) + + async def test_default_async_httpx2_client_constructs(self) -> None: + async with DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(_model_handler)) as client: + assert isinstance(client, httpx2.AsyncClient) + + def test_openai_accepts_default_httpx2_client(self) -> None: + with _sync_client(_model_handler) as client: + assert isinstance(client._client, httpx2.Client) + + async def test_async_openai_accepts_default_async_httpx2_client(self) -> None: + async with _async_client(_model_handler) as client: + assert isinstance(client._client, httpx2.AsyncClient) + + def test_sync_openai_rejects_async_httpx2_client(self) -> None: + # the message should name both accepted types now that the extra is installed + with pytest.raises(TypeError, match=r"`httpx\.Client` or `httpx2\.Client`"): + OpenAI(api_key=api_key, http_client=httpx2.AsyncClient()) + + async def test_async_openai_rejects_sync_httpx2_client(self) -> None: + with pytest.raises(TypeError, match=r"`httpx\.AsyncClient` or `httpx2\.AsyncClient`"): + AsyncOpenAI(api_key=api_key, http_client=httpx2.Client()) + + def test_default_httpx2_client_uses_sdk_timeout_not_httpx2_default(self) -> None: + # httpx2's own default is `Timeout(5.0)`; DefaultHttpx2Client explicitly sets + # the SDK's default (read=600, connect=5) instead. + with DefaultHttpx2Client(transport=httpx2.MockTransport(_model_handler)) as client: + _assert_sdk_default_timeout(client.timeout) + + def test_openai_with_default_httpx2_client_reports_sdk_timeout(self) -> None: + with _sync_client(_model_handler) as client: + _assert_sdk_default_timeout(client.timeout) + + async def test_async_openai_with_default_httpx2_client_reports_sdk_timeout(self) -> None: + async with _async_client(_model_handler) as client: + _assert_sdk_default_timeout(client.timeout) + + def test_bare_httpx2_client_gets_sdk_default_timeout(self) -> None: + # The footgun: a *bare* `httpx2.Client()` structurally reports httpx2's own + # 5s default, which is `!=` classic httpx's default across the class + # boundary. Without the fix in `_base_client.py`, `OpenAI` would mistake + # that for a user-supplied timeout and silently adopt the 5s value. + bare = httpx2.Client(transport=httpx2.MockTransport(_model_handler)) + with OpenAI(api_key=api_key, http_client=bare) as client: + _assert_sdk_default_timeout(client.timeout) + + async def test_bare_async_httpx2_client_gets_sdk_default_timeout(self) -> None: + bare = httpx2.AsyncClient(transport=httpx2.MockTransport(_model_handler)) + async with AsyncOpenAI(api_key=api_key, http_client=bare) as client: + _assert_sdk_default_timeout(client.timeout) + + def test_explicit_library_default_timeout_is_treated_as_uncustomised(self) -> None: + # Documented parity with classic httpx: the SDK's structural check treats a + # client whose timeout equals the *library's own default* (httpx2's 5s) as + # "not customised" and substitutes DEFAULT_TIMEOUT (600s) -- exactly as it + # already does for a classic `httpx.Client(timeout=5.0)`. Callers who truly + # want 5s must pass `timeout=` to `OpenAI(...)` directly. This asserts the + # behaviour is intentional, not an accident of the footgun fix. + explicit = httpx2.Client(timeout=httpx2.Timeout(5.0), transport=httpx2.MockTransport(_model_handler)) + with OpenAI(api_key=api_key, http_client=explicit) as client: + _assert_sdk_default_timeout(client.timeout) + + def test_non_default_httpx2_timeout_is_respected(self) -> None: + # Conversely, a genuinely non-default httpx2 timeout IS honoured (it differs + # from both the classic and httpx2 library defaults). + explicit = httpx2.Client(timeout=httpx2.Timeout(30.0), transport=httpx2.MockTransport(_model_handler)) + with OpenAI(api_key=api_key, http_client=explicit) as client: + timeout: Any = client.timeout + assert timeout.read == 30 + + +class TestUrlCoercion: + """A successful request implicitly proves the classic-httpx `URL` -> `str` + coercion at the one `build_request` call site: without it every request would + raise `TypeError: Expected str or httpx2.URL, got httpx.URL` before it ever + reaches the mock transport.""" + + def test_sync_get_returns_parsed_model(self) -> None: + with _sync_client(_model_handler) as client: + model = client.models.retrieve("gpt-4") + assert model.id == "gpt-4" + + async def test_async_get_returns_parsed_model(self) -> None: + async with _async_client(_model_handler) as client: + model = await client.models.retrieve("gpt-4") + assert model.id == "gpt-4" + + +class TestStatusMapping: + """Load-bearing: proves httpx2's *independent* `HTTPStatusError` is caught by + the widened `HTTP_STATUS_ERRORS` tuple. Without that widening, `raise_for_status()` + would raise an exception the `except HTTP_STATUS_ERRORS` clause doesn't see, and + it would fall through to the generic `except Exception` branch, surfacing as + `APIConnectionError` instead of the correct status-specific error.""" + + @pytest.mark.parametrize( + "status_code,expected_error", + [ + (400, BadRequestError), + (404, NotFoundError), + (429, RateLimitError), + (500, InternalServerError), + (402, APIStatusError), # generic 4xx with no dedicated subclass + ], + ) + def test_sync_status_mapping(self, status_code: int, expected_error: type[APIStatusError]) -> None: + with _sync_client(_status_handler(status_code), max_retries=0) as client: + with pytest.raises(expected_error) as excinfo: + client.models.retrieve("gpt-4") + assert type(excinfo.value) is expected_error + assert excinfo.value.status_code == status_code + + @pytest.mark.parametrize( + "status_code,expected_error", + [ + (400, BadRequestError), + (404, NotFoundError), + (429, RateLimitError), + (500, InternalServerError), + (402, APIStatusError), + ], + ) + async def test_async_status_mapping(self, status_code: int, expected_error: type[APIStatusError]) -> None: + async with _async_client(_status_handler(status_code), max_retries=0) as client: + with pytest.raises(expected_error) as excinfo: + await client.models.retrieve("gpt-4") + assert type(excinfo.value) is expected_error + assert excinfo.value.status_code == status_code + + +class TestTransportErrors: + """Proves the widened `TIMEOUT_EXCEPTIONS` tuple catches httpx2's own + `ReadTimeout`/`TimeoutException`, and that other httpx2 transport errors + (e.g. `ConnectError`) still fall through to `APIConnectionError` as expected.""" + + def test_sync_read_timeout_raises_api_timeout_error(self) -> None: + def handler(request: Any) -> Any: + raise httpx2.ReadTimeout("simulated read timeout", request=request) + + with _sync_client(handler, max_retries=0) as client: + with pytest.raises(APITimeoutError): + client.models.retrieve("gpt-4") + + async def test_async_read_timeout_raises_api_timeout_error(self) -> None: + def handler(request: Any) -> Any: + raise httpx2.ReadTimeout("simulated read timeout", request=request) + + async with _async_client(handler, max_retries=0) as client: + with pytest.raises(APITimeoutError): + await client.models.retrieve("gpt-4") + + def test_sync_connect_error_raises_api_connection_error(self) -> None: + def handler(request: Any) -> Any: + raise httpx2.ConnectError("simulated connect error", request=request) + + with _sync_client(handler, max_retries=0) as client: + with pytest.raises(APIConnectionError): + client.models.retrieve("gpt-4") + + async def test_async_connect_error_raises_api_connection_error(self) -> None: + def handler(request: Any) -> Any: + raise httpx2.ConnectError("simulated connect error", request=request) + + async with _async_client(handler, max_retries=0) as client: + with pytest.raises(APIConnectionError): + await client.models.retrieve("gpt-4") + + +class TestRetries: + """A 500 followed by a 200 should be retried transparently over the httpx2 + transport, and the retry count should be reflected in the + `x-stainless-retry-count` header -- proving retries work end-to-end on this + backend, not just the status-error mapping in isolation.""" + + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + def test_sync_retries_after_500_then_succeeds(self) -> None: + nb_calls = 0 + + def handler(_request: Any) -> Any: + nonlocal nb_calls + nb_calls += 1 + if nb_calls == 1: + return httpx2.Response(500, json=_error_body()) + return httpx2.Response(200, json=MODEL_JSON) + + with _sync_client(handler, max_retries=1) as client: + response = client.models.with_raw_response.retrieve("gpt-4") + + assert nb_calls == 2 + assert response.http_request.headers.get("x-stainless-retry-count") == "1" + assert response.parse().id == "gpt-4" + + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + async def test_async_retries_after_500_then_succeeds(self) -> None: + nb_calls = 0 + + def handler(_request: Any) -> Any: + nonlocal nb_calls + nb_calls += 1 + if nb_calls == 1: + return httpx2.Response(500, json=_error_body()) + return httpx2.Response(200, json=MODEL_JSON) + + async with _async_client(handler, max_retries=1) as client: + response = await client.models.with_raw_response.retrieve("gpt-4") + + assert nb_calls == 2 + assert response.http_request.headers.get("x-stainless-retry-count") == "1" + assert response.parse().id == "gpt-4" + + +class TestBedrockBodySigning: + """Bedrock SigV4 signing reads `request.content` to build the signature. With an + httpx2 backend the request is an `httpx2.Request`, and an unbuffered (streaming) + body raises httpx2's own `RequestNotRead` -- a distinct class from httpx's that + the widened `REQUEST_NOT_READ_ERRORS` tuple must catch so the caller sees the + actionable `OpenAIError` instead of a raw httpx2 exception.""" + + def test_httpx2_request_not_read_maps_to_openai_error(self) -> None: + from openai.providers.bedrock import _body_for_signing + + # an iterator body is a one-shot stream: `.content` raises RequestNotRead + # until the request is explicitly buffered with `.read()` + request: Any = httpx2.Request("POST", "http://localhost/test", content=iter([b"chunk"])) + with pytest.raises(OpenAIError, match="replayable request body"): + _body_for_signing(request) + + def test_buffered_httpx2_request_body_is_returned(self) -> None: + from openai.providers.bedrock import _body_for_signing + + request: Any = httpx2.Request("POST", "http://localhost/test", content=b"chunk") + assert _body_for_signing(request) == b"chunk" + + +class TestProviderAuth: + """Provider-managed clients (e.g. Bedrock) pass a no-op `Auth` to `send()` so the + provider's request hooks own authentication. httpx2's `send` rejects a classic + `httpx.Auth` instance (`TypeError: Invalid "auth" argument`) -- which the SDK's + generic exception handling would then surface as a misleading `APIConnectionError` + -- so the no-op auth must come from the client's own backend (`noop_auth_for`).""" + + def test_sync_bedrock_provider_over_httpx2(self) -> None: + from openai.providers import bedrock + + requests: list[Any] = [] + + def handler(request: Any) -> Any: + requests.append(request) + return httpx2.Response(200, json={}) + + client = OpenAI( + provider=bedrock(region="us-east-1", api_key="bedrock token"), + http_client=DefaultHttpx2Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + client.get("/models", cast_to=httpx.Response) + + assert requests[0].headers["Authorization"] == "Bearer bedrock token" + + async def test_async_bedrock_provider_over_httpx2(self) -> None: + from openai.providers import bedrock + + requests: list[Any] = [] + + def handler(request: Any) -> Any: + requests.append(request) + return httpx2.Response(200, json={}) + + client = AsyncOpenAI( + provider=bedrock(region="us-east-1", token_provider=lambda: "bedrock token"), + http_client=DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + await client.get("/models", cast_to=httpx.Response) + await client.close() + + assert requests[0].headers["Authorization"] == "Bearer bedrock token" + + +class TestStreamConsumed: + @pytest.mark.skip( + reason=( + "Not reproducible against httpx2.MockTransport: `httpx2.Response(status_code, " + "json=...)` eagerly reads its body into `self._content` at construction time " + "whenever it is built with `content=`/`json=`/`text=` (see `Response.__init__` " + "in httpx2/_models.py, which calls `self.read()` immediately for any " + "`ByteStream` body). Every subsequent `.read()`/`.iter_bytes()` call then hits " + "the cached-content fast path and never reaches the raw-stream branch that " + "raises `StreamConsumed`. Reproducing genuine double-consumption would require " + "a response built with a real `stream=`/`SyncByteStream` object standing in for " + "the transport's raw byte stream, which MockTransport's handler-based API does " + "not expose." + ) + ) + def test_double_read_raises_stream_already_consumed(self) -> None: ... diff --git a/tests/test_httpx2_not_installed.py b/tests/test_httpx2_not_installed.py new file mode 100644 index 0000000000..24231ecd1c --- /dev/null +++ b/tests/test_httpx2_not_installed.py @@ -0,0 +1,52 @@ +"""Graceful-degradation guard for when the `httpx2` extra is NOT installed. + +Runs in a subprocess with `httpx2` forced absent (``sys.modules["httpx2"] = None`` +makes ``import httpx2`` raise ``ImportError``), so it exercises the same path a +Python 3.9 / no-extra install hits -- regardless of whether httpx2 happens to be +installed in the current test environment. This is the one httpx2 test that is +meaningful when the extra is absent, so unlike ``test_httpx2_client.py`` it does +not ``importorskip`` it. +""" + +from __future__ import annotations + +import sys +import subprocess + +_PROGRAM = """ +import sys + +# Simulate the httpx2 extra not being installed. +sys.modules["httpx2"] = None + +import httpx +import openai +from openai import DefaultHttpx2Client, DefaultAsyncHttpx2Client + +# 1. importing openai must still work with httpx2 absent (zero behaviour change) +assert openai.OpenAI is not None + +# 2. the default httpx2 client factories must raise a helpful RuntimeError +for factory in (DefaultHttpx2Client, DefaultAsyncHttpx2Client): + try: + factory() + except RuntimeError as exc: + assert "httpx2" in str(exc), exc + else: + raise AssertionError(factory.__name__ + " did not raise RuntimeError when httpx2 is absent") + +# 3. a plain classic-httpx client is still accepted +openai.OpenAI(api_key="sk-test", http_client=httpx.Client()).close() + +print("OK") +""" + + +def test_import_and_stub_without_httpx2() -> None: + result = subprocess.run( + [sys.executable, "-c", _PROGRAM], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"stdout={result.stdout!r}\nstderr={result.stderr!r}" + assert result.stdout.strip().splitlines()[-1] == "OK"