From a8d0d73fb7394d6757977061368155a0f1030513 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:24:09 +0000 Subject: [PATCH 1/5] chore(deps): bump httpx2 in the python-security group across 1 directory Bumps the python-security group with 1 update in the / directory: [httpx2](https://github.com/pydantic/httpx2). Updates `httpx2` from 2.7.0 to 2.12.0 - [Release notes](https://github.com/pydantic/httpx2/releases) - [Changelog](https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md) - [Commits](https://github.com/pydantic/httpx2/compare/v2.7.0...v2.12.0) --- updated-dependencies: - dependency-name: httpx2 dependency-version: 2.12.0 dependency-type: direct:production dependency-group: python-security ... Signed-off-by: dependabot[bot] --- uv.lock | 50 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/uv.lock b/uv.lock index 45a0a6f081..8704d4b42d 100644 --- a/uv.lock +++ b/uv.lock @@ -3,10 +3,10 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", @@ -804,31 +804,41 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.7.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten' or (extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, + { name = "truststore", marker = "python_full_version < '3.11' or sys_platform != 'emscripten' or (extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] name = "httpx2" -version = "2.7.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten' or (extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten' or (extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, + { name = "httpx2-jsfetch", marker = "(python_full_version >= '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.12' and extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2') or (sys_platform != 'emscripten' and extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten' or (extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-6-openai-pydantic-v1' and extra == 'group-6-openai-pydantic-v2')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1435,10 +1445,10 @@ version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", @@ -1524,7 +1534,7 @@ wheels = [ [[package]] name = "openai" -version = "3.16.2" # x-release-please-version +version = "3.16.2" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -1891,10 +1901,10 @@ version = "1.10.26" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", @@ -1943,10 +1953,10 @@ version = "2.12.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", From 5c2addb5d92cf08f0bfa662791be7567e59c877d Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 21 Sep 2026 11:49:06 -0700 Subject: [PATCH 2/5] fix(deps): enforce and validate HTTPX2 security update --- examples/realtime/push_to_talk_app.py.lock | 29 ++-- pyproject.toml | 6 +- scripts/utils/validate-httpx2-wheel.py | 2 +- tests/test_dependency_constraints.py | 9 ++ tests/test_httpx2_decompression.py | 171 +++++++++++++++++++++ uv.lock | 5 +- 6 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 tests/test_httpx2_decompression.py diff --git a/examples/realtime/push_to_talk_app.py.lock b/examples/realtime/push_to_talk_app.py.lock index b4e5403afb..30c345313e 100644 --- a/examples/realtime/push_to_talk_app.py.lock +++ b/examples/realtime/push_to_talk_app.py.lock @@ -177,20 +177,20 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] name = "httpx2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, @@ -200,9 +200,9 @@ dependencies = [ { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, ] [[package]] @@ -597,7 +597,7 @@ wheels = [ [[package]] name = "openai" -version = "3.3.0" +version = "3.16.2" source = { editable = "../../" } dependencies = [ { name = "anyio" }, @@ -618,12 +618,12 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.14.3" }, { name = "anyio", specifier = ">=4.10.0,<5" }, { name = "botocore", marker = "extra == 'bedrock'", specifier = ">=1.40.0,<2" }, - { name = "httpx2", specifier = ">=2.7.0,<3" }, - { name = "jiter", specifier = ">=0.10.0,<1" }, + { name = "httpx2", specifier = ">=2.12.0,<3" }, + { name = "jiter", specifier = ">=0.16.0,<1" }, { name = "numpy", marker = "extra == 'datalib'", specifier = ">=1" }, { name = "numpy", marker = "extra == 'voice-helpers'", specifier = ">=2.0.2" }, { name = "pandas", marker = "extra == 'datalib'", specifier = ">=1.2.3" }, - { name = "pydantic", specifier = ">=1.9.0,<3" }, + { name = "pydantic", specifier = ">=1.10.13,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,<3" }, { name = "sniffio" }, { name = "sounddevice", marker = "extra == 'voice-helpers'", specifier = ">=0.5.1" }, { name = "typing-extensions", specifier = ">=4.14,<5" }, @@ -634,7 +634,7 @@ provides-extras = ["aiohttp", "realtime", "datalib", "voice-helpers", "bedrock"] [package.metadata.requires-dev] build = [ - { name = "hatchling", specifier = "==1.26.3" }, + { name = "hatchling", specifier = "==1.27.0" }, { name = "packaging", specifier = "==26.3" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "pluggy", specifier = "==1.6.0" }, @@ -646,11 +646,10 @@ dev = [ { name = "botocore", specifier = "==1.42.97" }, { name = "griffe", specifier = ">=1" }, { name = "inline-snapshot", specifier = ">=0.28.0" }, - { name = "mypy", specifier = "==1.17" }, + { name = "mypy", specifier = "==2.3.1" }, { name = "pandas-stubs", specifier = ">=1.1.0.11" }, - { name = "pyright", specifier = "==1.1.399" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "rich", specifier = ">=13.7.1" }, { name = "ruff" }, diff --git a/pyproject.toml b/pyproject.toml index 231a977e3a..2104968afa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [ ] dependencies = [ - "httpx2>=2.7.0, <3", + "httpx2>=2.12.0, <3", "pydantic>=1.10.13, <3, !=2.0.*, !=2.1.*, !=2.2.*, !=2.3.*", "typing-extensions>=4.14, <5", "anyio>=4.10.0, <5", @@ -84,9 +84,11 @@ required-version = ">=0.12.1" exclude-newer = "8 days" default-groups = ["dev", "pydantic-v2"] conflicts = [[{ group = "pydantic-v1" }, { group = "pydantic-v2" }]] -# Security floors for transitive development dependencies. Constraints do not +# Security floors for transitive dependencies. Constraints do not # install these packages into the SDK's runtime environment. constraint-dependencies = [ + # HTTPX2 also publishes an exact pin to its matching httpcore2 release. + "httpcore2>=2.12.0", "azure-core>=1.38.0", "cryptography>=50.0.0", "msal>=1.37.0", diff --git a/scripts/utils/validate-httpx2-wheel.py b/scripts/utils/validate-httpx2-wheel.py index 2d3e0bba5d..e1ea16140b 100644 --- a/scripts/utils/validate-httpx2-wheel.py +++ b/scripts/utils/validate-httpx2-wheel.py @@ -45,7 +45,7 @@ def validate_metadata(wheel: Path) -> None: if metadata["Requires-Python"] != ">=3.10": raise RuntimeError(f"Expected Python >=3.10, found: {metadata['Requires-Python']}") - for expected in ("httpx2<3,>=2.7.0", "anyio<5,>=4.10.0"): + for expected in ("httpx2<3,>=2.12.0", "anyio<5,>=4.10.0"): if not any(value.startswith(expected) for value in base): raise RuntimeError(f"Expected the base wheel to require {expected}: {base}") if any(requirement_name(value) == "httpx" for value in requirements): diff --git a/tests/test_dependency_constraints.py b/tests/test_dependency_constraints.py index ec5e13da1d..07cd73f7e4 100644 --- a/tests/test_dependency_constraints.py +++ b/tests/test_dependency_constraints.py @@ -6,6 +6,15 @@ from packaging.requirements import Requirement +def test_httpx2_security_floor() -> None: + requirements = [Requirement(value) for value in requires("openai") or []] + requirement = next(requirement for requirement in requirements if requirement.name == "httpx2") + assert requirement.marker is None + assert "2.11.0" not in requirement.specifier + assert "2.12.0" in requirement.specifier + assert "3.0.0" not in requirement.specifier + + @pytest.mark.parametrize( ("name", "extra", "affected", "patched"), [ diff --git a/tests/test_httpx2_decompression.py b/tests/test_httpx2_decompression.py new file mode 100644 index 0000000000..7cf914b8ac --- /dev/null +++ b/tests/test_httpx2_decompression.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import gzip +import json +import zlib +from typing import Any, Callable, Iterator, AsyncIterator +from typing_extensions import override + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI + +from .test_large_payload_contract import PAYLOAD_SIZE, assert_intact, response_body + + +class CompressedStream(httpx2.SyncByteStream): + def __init__(self, data: bytes) -> None: + self.data = data + self.closed = False + + @override + def __iter__(self) -> Iterator[bytes]: + # One small network chunk must not inflate into one enormous chunk. + yield self.data + + @override + def close(self) -> None: + self.closed = True + + +class AsyncCompressedStream(httpx2.AsyncByteStream): + def __init__(self, data: bytes) -> None: + self.data = data + self.closed = False + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self.data + + @override + async def aclose(self) -> None: + self.closed = True + + +def compressed_response( + stream: CompressedStream | AsyncCompressedStream, encoding: str, content_type: str = "application/json" +) -> httpx2.Response: + # Passing a stream avoids eager decoding in Response's constructor. + return httpx2.Response(200, headers={"content-encoding": encoding, "content-type": content_type}, stream=stream) + + +async def check_large_compressed_responses(encoding: str, compress: Callable[[bytes], bytes]) -> None: + text = "x" * PAYLOAD_SIZE + body = json.dumps(response_body(text)).encode() + data = compress(body) + stream = CompressedStream(data) + with OpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding))), + ) as client: + with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response: + size = 0 + for chunk in response.iter_bytes(): + # The upstream decoder's chunk bound is not a response-size cap. + assert len(chunk) <= 1024 * 1024 + size += len(chunk) + assert size == len(body) + assert stream.closed + stream = CompressedStream(data) + assert_intact(client.responses.create(model="gpt-4o-mini", input="Hello").output_text, text) + assert stream.closed + + async_stream = AsyncCompressedStream(data) + async with AsyncOpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding)) + ), + ) as async_client: + async with async_client.responses.with_streaming_response.create( + model="gpt-4o-mini", input="Hello" + ) as async_response: + size = 0 + async for chunk in async_response.iter_bytes(): + assert len(chunk) <= 1024 * 1024 + size += len(chunk) + assert size == len(body) + assert async_stream.closed + async_stream = AsyncCompressedStream(data) + result = await async_client.responses.create(model="gpt-4o-mini", input="Hello") + assert_intact(result.output_text, text) + assert async_stream.closed + + event: dict[str, Any] = { + "type": "response.output_text.delta", + "delta": text, + "item_id": "msg_test", + "output_index": 0, + "content_index": 0, + "sequence_number": 0, + "logprobs": [], + } + data = compress(b"data: " + json.dumps(event).encode() + b"\n\ndata: [DONE]\n\n") + stream = CompressedStream(data) + with OpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.Client( + transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding, "text/event-stream")) + ), + ) as client: + with client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as events: + received = list(events) + assert len(received) == 1 and received[0].type == "response.output_text.delta" + assert_intact(received[0].delta, text) + assert stream.closed + + async_stream = AsyncCompressedStream(data) + async with AsyncOpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding, "text/event-stream")) + ), + ) as async_client: + async with await async_client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as async_events: + received = [event async for event in async_events] + assert len(received) == 1 and received[0].type == "response.output_text.delta" + assert_intact(received[0].delta, text) + assert async_stream.closed + + +async def test_large_compressed_responses() -> None: + # Keep all high-memory cases sequential, including under pytest-xdist. + await check_large_compressed_responses("gzip", gzip.compress) + await check_large_compressed_responses("deflate", zlib.compress) + + +@pytest.mark.parametrize("encoding", ["gzip", "deflate"]) +async def test_decoding_failure_closes_response_stream(encoding: str) -> None: + stream = CompressedStream(b"not a compressed stream") + with OpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding))), + ) as client: + with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response: + with pytest.raises(httpx2.DecodingError): + list(response.iter_bytes()) + # Check before context-manager cleanup can hide an upstream leak. + assert stream.closed + assert response.http_response.is_closed + + async_stream = AsyncCompressedStream(b"not a compressed stream") + async with AsyncOpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding)) + ), + ) as async_client: + async with async_client.responses.with_streaming_response.create( + model="gpt-4o-mini", input="Hello" + ) as async_response: + with pytest.raises(httpx2.DecodingError): + _ = [chunk async for chunk in async_response.iter_bytes()] + assert async_stream.closed + assert async_response.http_response.is_closed diff --git a/uv.lock b/uv.lock index 8704d4b42d..1325814bc6 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,7 @@ exclude-newer-span = "P8D" constraints = [ { name = "azure-core", specifier = ">=1.38.0" }, { name = "cryptography", specifier = ">=50.0.0" }, + { name = "httpcore2", specifier = ">=2.12.0" }, { name = "msal", specifier = ">=1.37.0" }, { name = "pygments", specifier = ">=2.20.0" }, { name = "pyjwt", specifier = ">=2.13.0" }, @@ -1534,7 +1535,7 @@ wheels = [ [[package]] name = "openai" -version = "3.16.2" +version = "3.16.2" # x-release-please-version source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -1603,7 +1604,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.14.3" }, { name = "anyio", specifier = ">=4.10.0,<5" }, { name = "botocore", marker = "extra == 'bedrock'", specifier = ">=1.40.0,<2" }, - { name = "httpx2", specifier = ">=2.7.0,<3" }, + { name = "httpx2", specifier = ">=2.12.0,<3" }, { name = "jiter", specifier = ">=0.16.0,<1" }, { name = "numpy", marker = "extra == 'datalib'", specifier = ">=1" }, { name = "numpy", marker = "extra == 'voice-helpers'", specifier = ">=2.0.2" }, From 4acb30c5c906f3a093232e074ca782b6f717431e Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 21 Sep 2026 11:50:28 -0700 Subject: [PATCH 3/5] fix(tests): align policy and header assertions with HTTPX2 2.12 --- scripts/check-python-version-policy.py | 2 +- tests/test_client.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/check-python-version-policy.py b/scripts/check-python-version-policy.py index 9571360ceb..ae57734836 100644 --- a/scripts/check-python-version-policy.py +++ b/scripts/check-python-version-policy.py @@ -10,7 +10,7 @@ PRERELEASE = "3.15" UNMARKED_DEPENDENCIES = ( "aiohttp>=3.14.3", - "httpx2>=2.7.0, <3", + "httpx2>=2.12.0, <3", "anyio>=4.10.0, <5", "botocore>=1.40.0,<2", ) diff --git a/tests/test_client.py b/tests/test_client.py index b77b78cb9d..3fd6b8fa41 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1279,7 +1279,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response: ) assert response.retries_taken == failures_before_success - assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1374,7 +1374,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response: model="gpt-5.4", ) as response: assert response.retries_taken == failures_before_success - assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly @@ -2629,7 +2629,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response: ) assert response.retries_taken == failures_before_success - assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -2724,7 +2724,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response: model="gpt-5.4", ) as response: assert response.retries_taken == failures_before_success - assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success async def test_get_platform(self) -> None: platform = await asyncify(get_platform)() From ab48dd49784432384f6a7de8d2d4d8550e8a80fc Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 21 Sep 2026 11:55:32 -0700 Subject: [PATCH 4/5] test: serialize compressed and uncompressed large payload probes --- tests/test_httpx2_decompression.py | 133 +-------------------------- tests/test_large_payload_contract.py | 124 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 132 deletions(-) diff --git a/tests/test_httpx2_decompression.py b/tests/test_httpx2_decompression.py index 7cf914b8ac..e2348d637d 100644 --- a/tests/test_httpx2_decompression.py +++ b/tests/test_httpx2_decompression.py @@ -1,142 +1,11 @@ from __future__ import annotations -import gzip -import json -import zlib -from typing import Any, Callable, Iterator, AsyncIterator -from typing_extensions import override - import httpx2 import pytest from openai import OpenAI, AsyncOpenAI -from .test_large_payload_contract import PAYLOAD_SIZE, assert_intact, response_body - - -class CompressedStream(httpx2.SyncByteStream): - def __init__(self, data: bytes) -> None: - self.data = data - self.closed = False - - @override - def __iter__(self) -> Iterator[bytes]: - # One small network chunk must not inflate into one enormous chunk. - yield self.data - - @override - def close(self) -> None: - self.closed = True - - -class AsyncCompressedStream(httpx2.AsyncByteStream): - def __init__(self, data: bytes) -> None: - self.data = data - self.closed = False - - @override - async def __aiter__(self) -> AsyncIterator[bytes]: - yield self.data - - @override - async def aclose(self) -> None: - self.closed = True - - -def compressed_response( - stream: CompressedStream | AsyncCompressedStream, encoding: str, content_type: str = "application/json" -) -> httpx2.Response: - # Passing a stream avoids eager decoding in Response's constructor. - return httpx2.Response(200, headers={"content-encoding": encoding, "content-type": content_type}, stream=stream) - - -async def check_large_compressed_responses(encoding: str, compress: Callable[[bytes], bytes]) -> None: - text = "x" * PAYLOAD_SIZE - body = json.dumps(response_body(text)).encode() - data = compress(body) - stream = CompressedStream(data) - with OpenAI( - api_key="test-key", - max_retries=0, - http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding))), - ) as client: - with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response: - size = 0 - for chunk in response.iter_bytes(): - # The upstream decoder's chunk bound is not a response-size cap. - assert len(chunk) <= 1024 * 1024 - size += len(chunk) - assert size == len(body) - assert stream.closed - stream = CompressedStream(data) - assert_intact(client.responses.create(model="gpt-4o-mini", input="Hello").output_text, text) - assert stream.closed - - async_stream = AsyncCompressedStream(data) - async with AsyncOpenAI( - api_key="test-key", - max_retries=0, - http_client=httpx2.AsyncClient( - transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding)) - ), - ) as async_client: - async with async_client.responses.with_streaming_response.create( - model="gpt-4o-mini", input="Hello" - ) as async_response: - size = 0 - async for chunk in async_response.iter_bytes(): - assert len(chunk) <= 1024 * 1024 - size += len(chunk) - assert size == len(body) - assert async_stream.closed - async_stream = AsyncCompressedStream(data) - result = await async_client.responses.create(model="gpt-4o-mini", input="Hello") - assert_intact(result.output_text, text) - assert async_stream.closed - - event: dict[str, Any] = { - "type": "response.output_text.delta", - "delta": text, - "item_id": "msg_test", - "output_index": 0, - "content_index": 0, - "sequence_number": 0, - "logprobs": [], - } - data = compress(b"data: " + json.dumps(event).encode() + b"\n\ndata: [DONE]\n\n") - stream = CompressedStream(data) - with OpenAI( - api_key="test-key", - max_retries=0, - http_client=httpx2.Client( - transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding, "text/event-stream")) - ), - ) as client: - with client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as events: - received = list(events) - assert len(received) == 1 and received[0].type == "response.output_text.delta" - assert_intact(received[0].delta, text) - assert stream.closed - - async_stream = AsyncCompressedStream(data) - async with AsyncOpenAI( - api_key="test-key", - max_retries=0, - http_client=httpx2.AsyncClient( - transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding, "text/event-stream")) - ), - ) as async_client: - async with await async_client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as async_events: - received = [event async for event in async_events] - assert len(received) == 1 and received[0].type == "response.output_text.delta" - assert_intact(received[0].delta, text) - assert async_stream.closed - - -async def test_large_compressed_responses() -> None: - # Keep all high-memory cases sequential, including under pytest-xdist. - await check_large_compressed_responses("gzip", gzip.compress) - await check_large_compressed_responses("deflate", zlib.compress) +from .test_large_payload_contract import CompressedStream, AsyncCompressedStream, compressed_response @pytest.mark.parametrize("encoding", ["gzip", "deflate"]) diff --git a/tests/test_large_payload_contract.py b/tests/test_large_payload_contract.py index 4e45cd75ce..62df7a10b1 100644 --- a/tests/test_large_payload_contract.py +++ b/tests/test_large_payload_contract.py @@ -1,6 +1,8 @@ from __future__ import annotations +import gzip import json +import zlib from typing import Any, Callable, Iterator, AsyncIterator from typing_extensions import override @@ -224,9 +226,131 @@ def events() -> Iterator[dict[str, Any]]: assert_intact(message.parsed.value, value) +class CompressedStream(httpx2.SyncByteStream): + def __init__(self, data: bytes) -> None: + self.data = data + self.closed = False + + @override + def __iter__(self) -> Iterator[bytes]: + # One small network chunk must not inflate into one enormous chunk. + yield self.data + + @override + def close(self) -> None: + self.closed = True + + +class AsyncCompressedStream(httpx2.AsyncByteStream): + def __init__(self, data: bytes) -> None: + self.data = data + self.closed = False + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self.data + + @override + async def aclose(self) -> None: + self.closed = True + + +def compressed_response( + stream: CompressedStream | AsyncCompressedStream, encoding: str, content_type: str = "application/json" +) -> httpx2.Response: + # Passing a stream avoids eager decoding in Response's constructor. + return httpx2.Response(200, headers={"content-encoding": encoding, "content-type": content_type}, stream=stream) + + +async def check_large_compressed_responses(encoding: str, compress: Callable[[bytes], bytes]) -> None: + text = "x" * PAYLOAD_SIZE + body = json.dumps(response_body(text)).encode() + data = compress(body) + stream = CompressedStream(data) + with OpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding))), + ) as client: + with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response: + size = 0 + for chunk in response.iter_bytes(): + # The upstream decoder's chunk bound is not a response-size cap. + assert len(chunk) <= 1024 * 1024 + size += len(chunk) + assert size == len(body) + assert stream.closed + stream = CompressedStream(data) + assert_intact(client.responses.create(model="gpt-4o-mini", input="Hello").output_text, text) + assert stream.closed + + async_stream = AsyncCompressedStream(data) + async with AsyncOpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding)) + ), + ) as async_client: + async with async_client.responses.with_streaming_response.create( + model="gpt-4o-mini", input="Hello" + ) as async_response: + size = 0 + async for chunk in async_response.iter_bytes(): + assert len(chunk) <= 1024 * 1024 + size += len(chunk) + assert size == len(body) + assert async_stream.closed + async_stream = AsyncCompressedStream(data) + result = await async_client.responses.create(model="gpt-4o-mini", input="Hello") + assert_intact(result.output_text, text) + assert async_stream.closed + + event: dict[str, Any] = { + "type": "response.output_text.delta", + "delta": text, + "item_id": "msg_test", + "output_index": 0, + "content_index": 0, + "sequence_number": 0, + "logprobs": [], + } + data = compress(b"data: " + json.dumps(event).encode() + b"\n\ndata: [DONE]\n\n") + stream = CompressedStream(data) + with OpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.Client( + transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding, "text/event-stream")) + ), + ) as client: + with client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as events: + received = list(events) + assert len(received) == 1 and received[0].type == "response.output_text.delta" + assert_intact(received[0].delta, text) + assert stream.closed + + async_stream = AsyncCompressedStream(data) + async with AsyncOpenAI( + api_key="test-key", + max_retries=0, + http_client=httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding, "text/event-stream")) + ), + ) as async_client: + async with await async_client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as async_events: + received = [event async for event in async_events] + assert len(received) == 1 and received[0].type == "response.output_text.delta" + assert_intact(received[0].delta, text) + assert async_stream.closed + + async def test_large_payload_contract() -> None: # One test prevents pytest-xdist from running high-memory cases together. check_blocking_json() await check_structured_json() await check_responses_stream() check_chat_stream() + + await check_large_compressed_responses("gzip", gzip.compress) + await check_large_compressed_responses("deflate", zlib.compress) From 899727a4939f2c87b613578a1933442e6882dbe1 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 21 Sep 2026 13:23:38 -0700 Subject: [PATCH 5/5] test: trim redundant HTTPX2 upgrade coverage --- tests/test_dependency_constraints.py | 9 ---- tests/test_large_payload_contract.py | 67 +++++----------------------- 2 files changed, 11 insertions(+), 65 deletions(-) diff --git a/tests/test_dependency_constraints.py b/tests/test_dependency_constraints.py index 07cd73f7e4..ec5e13da1d 100644 --- a/tests/test_dependency_constraints.py +++ b/tests/test_dependency_constraints.py @@ -6,15 +6,6 @@ from packaging.requirements import Requirement -def test_httpx2_security_floor() -> None: - requirements = [Requirement(value) for value in requires("openai") or []] - requirement = next(requirement for requirement in requirements if requirement.name == "httpx2") - assert requirement.marker is None - assert "2.11.0" not in requirement.specifier - assert "2.12.0" in requirement.specifier - assert "3.0.0" not in requirement.specifier - - @pytest.mark.parametrize( ("name", "extra", "affected", "patched"), [ diff --git a/tests/test_large_payload_contract.py b/tests/test_large_payload_contract.py index 62df7a10b1..00b85a8e96 100644 --- a/tests/test_large_payload_contract.py +++ b/tests/test_large_payload_contract.py @@ -2,7 +2,6 @@ import gzip import json -import zlib from typing import Any, Callable, Iterator, AsyncIterator from typing_extensions import override @@ -255,41 +254,39 @@ async def aclose(self) -> None: self.closed = True -def compressed_response( - stream: CompressedStream | AsyncCompressedStream, encoding: str, content_type: str = "application/json" -) -> httpx2.Response: +def compressed_response(stream: CompressedStream | AsyncCompressedStream, encoding: str) -> httpx2.Response: # Passing a stream avoids eager decoding in Response's constructor. - return httpx2.Response(200, headers={"content-encoding": encoding, "content-type": content_type}, stream=stream) + return httpx2.Response( + 200, headers={"content-encoding": encoding, "content-type": "application/json"}, stream=stream + ) -async def check_large_compressed_responses(encoding: str, compress: Callable[[bytes], bytes]) -> None: +async def check_large_compressed_responses() -> None: text = "x" * PAYLOAD_SIZE body = json.dumps(response_body(text)).encode() - data = compress(body) + data = gzip.compress(body) stream = CompressedStream(data) with OpenAI( api_key="test-key", max_retries=0, - http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding))), + http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, "gzip"))), ) as client: with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response: size = 0 for chunk in response.iter_bytes(): # The upstream decoder's chunk bound is not a response-size cap. assert len(chunk) <= 1024 * 1024 + assert chunk == body[size : size + len(chunk)] size += len(chunk) assert size == len(body) assert stream.closed - stream = CompressedStream(data) - assert_intact(client.responses.create(model="gpt-4o-mini", input="Hello").output_text, text) - assert stream.closed async_stream = AsyncCompressedStream(data) async with AsyncOpenAI( api_key="test-key", max_retries=0, http_client=httpx2.AsyncClient( - transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding)) + transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, "gzip")) ), ) as async_client: async with async_client.responses.with_streaming_response.create( @@ -298,51 +295,10 @@ async def check_large_compressed_responses(encoding: str, compress: Callable[[by size = 0 async for chunk in async_response.iter_bytes(): assert len(chunk) <= 1024 * 1024 + assert chunk == body[size : size + len(chunk)] size += len(chunk) assert size == len(body) assert async_stream.closed - async_stream = AsyncCompressedStream(data) - result = await async_client.responses.create(model="gpt-4o-mini", input="Hello") - assert_intact(result.output_text, text) - assert async_stream.closed - - event: dict[str, Any] = { - "type": "response.output_text.delta", - "delta": text, - "item_id": "msg_test", - "output_index": 0, - "content_index": 0, - "sequence_number": 0, - "logprobs": [], - } - data = compress(b"data: " + json.dumps(event).encode() + b"\n\ndata: [DONE]\n\n") - stream = CompressedStream(data) - with OpenAI( - api_key="test-key", - max_retries=0, - http_client=httpx2.Client( - transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding, "text/event-stream")) - ), - ) as client: - with client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as events: - received = list(events) - assert len(received) == 1 and received[0].type == "response.output_text.delta" - assert_intact(received[0].delta, text) - assert stream.closed - - async_stream = AsyncCompressedStream(data) - async with AsyncOpenAI( - api_key="test-key", - max_retries=0, - http_client=httpx2.AsyncClient( - transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding, "text/event-stream")) - ), - ) as async_client: - async with await async_client.responses.create(model="gpt-4o-mini", input="Hello", stream=True) as async_events: - received = [event async for event in async_events] - assert len(received) == 1 and received[0].type == "response.output_text.delta" - assert_intact(received[0].delta, text) - assert async_stream.closed async def test_large_payload_contract() -> None: @@ -352,5 +308,4 @@ async def test_large_payload_contract() -> None: await check_responses_stream() check_chat_stream() - await check_large_compressed_responses("gzip", gzip.compress) - await check_large_compressed_responses("deflate", zlib.compress) + await check_large_compressed_responses()