From fc70b132f4c214b6f69723f9fb41e72545097d55 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Fri, 7 Aug 2026 12:42:47 +0530 Subject: [PATCH 1/8] fix: sanitize newlines in NO_PROXY before httpx client init (#3303) httpx's get_environment_proxies() only splits on commas, so a trailing newline in NO_PROXY (common in Docker/.env files) becomes part of the hostname and httpx raises InvalidURL. The previous implementation permanently mutated os.environ, which leaked into unrelated clients in the same process and ignored trust_env=False. Replace the unconditional mutation with a context manager that temporarily normalizes NO_PROXY/no_proxy only for the duration of httpx client construction, then restores the original values. Skip the normalization entirely when the caller passes trust_env=False. Add 9 regression tests covering sync/async construction, env restoration, trust_env=False, lowercase no_proxy, and multiple newlines. --- src/openai/_base_client.py | 56 ++++++++- tests/test_no_proxy_sanitize.py | 199 ++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 tests/test_no_proxy_sanitize.py diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index f195d04816..4534722e68 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys import json import math @@ -11,6 +12,7 @@ import logging import platform import warnings +import contextlib import email.utils from types import TracebackType from random import random @@ -860,12 +862,50 @@ def _idempotency_key(self) -> str: return f"stainless-python-retry-{uuid.uuid4()}" + +@contextlib.contextmanager +def _sanitized_no_proxy() -> Iterator[None]: + """Temporarily normalize line separators in NO_PROXY/no_proxy for the + duration of a httpx client construction. + + httpx's ``get_environment_proxies()`` only splits on commas, so a trailing + newline or carriage return in ``NO_PROXY`` (common in Docker/``.env`` files + or CRLF values where the ``\\n`` was stripped but ``\\r`` remains) becomes + part of the hostname and httpx raises ``InvalidURL`` (issue #3303). httpx + reads the environment once during ``__init__``, so we only need the + sanitized value to be visible for that window and then restore the original + afterwards — this avoids permanently mutating process-global state for + unrelated clients. + """ + originals: dict[str, str] = {} + try: + for key in ("NO_PROXY", "no_proxy"): + val = os.environ.get(key) + if val and any(c in val for c in "\n\r"): + originals[key] = val + # splitlines() handles \n, \r, \r\n, and other Unicode line + # separators uniformly. + parts = [part.strip() for part in val.splitlines()] + os.environ[key] = ",".join(p for p in parts if p) + yield + finally: + for key, val in originals.items(): + os.environ[key] = val + + class _DefaultHttpxClient(httpx2.Client): def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) + # httpx reads proxy env vars during __init__; temporarily normalize + # newlines in NO_PROXY so they don't become part of the hostname + # (issue #3303). Skip when the caller opted out of env-based proxies. + if kwargs.get("trust_env", True): + with _sanitized_no_proxy(): + super().__init__(**kwargs) + else: + super().__init__(**kwargs) if TYPE_CHECKING: @@ -1459,7 +1499,12 @@ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) + # See _DefaultHttpxClient for the rationale behind the NO_PROXY guard. + if kwargs.get("trust_env", True): + with _sanitized_no_proxy(): + super().__init__(**kwargs) + else: + super().__init__(**kwargs) _DefaultAioHttpClient: type[httpx2.AsyncClient] @@ -1480,7 +1525,12 @@ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) + # See _DefaultHttpxClient for the rationale behind the NO_PROXY guard. + if kwargs.get("trust_env", True): + with _sanitized_no_proxy(): + super().__init__(**kwargs) + else: + super().__init__(**kwargs) _DefaultAioHttpClient = _InstalledAioHttpClient diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py new file mode 100644 index 0000000000..b3f2bd1e42 --- /dev/null +++ b/tests/test_no_proxy_sanitize.py @@ -0,0 +1,199 @@ +# Regression tests for NO_PROXY newline sanitization (issue #3303). +# +# httpx's ``get_environment_proxies()`` only splits on commas, so a trailing +# newline in ``NO_PROXY`` becomes part of the hostname and httpx raises +# ``InvalidURL``. The SDK temporarily normalizes the env var during client +# construction and restores it afterwards, so unrelated clients are unaffected. + +from __future__ import annotations + +import pytest + + +def _set_no_proxy(monkeypatch: pytest.MonkeyPatch, value: str | None) -> None: + """Set both NO_PROXY and no_proxy via monkeypatch for automatic cleanup.""" + if value is None: + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + else: + monkeypatch.setenv("NO_PROXY", value) + monkeypatch.setenv("no_proxy", value) + + +def _mount_patterns(client: object) -> list[str]: + return [k.pattern for k in client._mounts] # type: ignore[attr-defined] + + +def test_sync_client_construction_with_newline_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """A sync default client can be constructed when NO_PROXY has newlines.""" + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + # Should not raise InvalidURL + client = _DefaultHttpxClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + client.close() + + +def test_async_client_construction_with_newline_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """An async default client can be constructed when NO_PROXY has newlines.""" + from openai._base_client import _DefaultAsyncHttpxClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + # Should not raise InvalidURL + client = _DefaultAsyncHttpxClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + + +def test_env_restored_after_sync_client_construction(monkeypatch: pytest.MonkeyPatch) -> None: + """os.environ is restored to its original value after client construction.""" + from openai._base_client import _DefaultHttpxClient + + original = "localhost\n127.0.0.1" + _set_no_proxy(monkeypatch, original) + client = _DefaultHttpxClient() + client.close() + import os + + assert os.environ.get("NO_PROXY") == original + assert os.environ.get("no_proxy") == original + + +def test_env_restored_after_async_client_construction(monkeypatch: pytest.MonkeyPatch) -> None: + """os.environ is restored after async client construction.""" + from openai._base_client import _DefaultAsyncHttpxClient + + original = "localhost\n127.0.0.1" + _set_no_proxy(monkeypatch, original) + _DefaultAsyncHttpxClient() + import os + + assert os.environ.get("NO_PROXY") == original + assert os.environ.get("no_proxy") == original + + +def test_trust_env_false_skips_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: + """When trust_env=False, NO_PROXY is not touched and no InvalidURL is raised.""" + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + client = _DefaultHttpxClient(trust_env=False) + import os + + # env should be untouched + assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" + # no proxy mounts should be configured since trust_env=False + assert client._mounts == {} + client.close() + + +def test_trust_env_false_async_skips_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: + """Async client with trust_env=False skips NO_PROXY sanitization.""" + from openai._base_client import _DefaultAsyncHttpxClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + client = _DefaultAsyncHttpxClient(trust_env=False) + import os + + assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" + assert client._mounts == {} + + +def test_no_newline_no_mutation(monkeypatch: pytest.MonkeyPatch) -> None: + """When NO_PROXY has no newlines, the env var is not modified at all.""" + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost,127.0.0.1") + client = _DefaultHttpxClient() + client.close() + import os + + assert os.environ.get("NO_PROXY") == "localhost,127.0.0.1" + + +def test_lowercase_no_proxy_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + """Lowercase no_proxy is also sanitized.""" + from openai._base_client import _DefaultHttpxClient + + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.setenv("no_proxy", "localhost\n127.0.0.1") + client = _DefaultHttpxClient() + client.close() + import os + + # restored after construction + assert os.environ.get("no_proxy") == "localhost\n127.0.0.1" + + +def test_multiple_newlines_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + """Multiple newlines and whitespace are handled correctly.""" + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost\n\n127.0.0.1\n.example.com\n") + client = _DefaultHttpxClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + assert any("example.com" in p for p in patterns) + client.close() + import os + + # restored + assert os.environ.get("NO_PROXY") == "localhost\n\n127.0.0.1\n.example.com\n" + + +def test_carriage_return_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + """A lone \\r (from CRLF files where \\n was stripped) is also sanitized.""" + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost\r127.0.0.1") + client = _DefaultHttpxClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + client.close() + import os + + assert os.environ.get("NO_PROXY") == "localhost\r127.0.0.1" + + +def test_crlf_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + """CRLF (\\r\\n) line endings are sanitized correctly.""" + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost\r\n127.0.0.1\r\n") + client = _DefaultHttpxClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + client.close() + + +def test_aiohttp_client_construction_with_newline_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """The aiohttp transport client also sanitizes NO_PROXY newlines.""" + pytest.importorskip("httpx_aiohttp") + from openai._base_client import _DefaultAioHttpClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + # Should not raise InvalidURL + client = _DefaultAioHttpClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + + +def test_aiohttp_client_trust_env_false_skips_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: + """The aiohttp transport client respects trust_env=False.""" + pytest.importorskip("httpx_aiohttp") + from openai._base_client import _DefaultAioHttpClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + client = _DefaultAioHttpClient(trust_env=False) + import os + + assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" + assert client._mounts == {} From c3816eb7b36919010274e9e758a170a5c3322006 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Fri, 7 Aug 2026 18:07:45 +0530 Subject: [PATCH 2/8] fix: serialize NO_PROXY sanitization across concurrent client constructions Addresses Codex P2: when two SDK default clients are constructed concurrently while NO_PROXY contains a newline, one call can enter the context after another already sanitized the process-wide env, record no original value, and then the first call restores the invalid value before the second call's super().__init__() reaches httpx's env proxy parsing. That leaves the second client exposed to the same InvalidURL this change is trying to prevent. Added a module-level threading.Lock (_no_proxy_sanitizer_lock) that wraps the entire sanitize-construct-restore window in _sanitized_no_proxy, so concurrent client constructions are serialized and each call sees a consistent environment. Added test_concurrent_client_construction_serializes_sanitization that spawns 10 threads constructing clients with a newline NO_PROXY and verifies no errors and the original value is restored. --- src/openai/_base_client.py | 36 ++++++++++++++++++++------------- tests/test_no_proxy_sanitize.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 4534722e68..c1d01a99f2 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -12,6 +12,7 @@ import logging import platform import warnings +import threading import contextlib import email.utils from types import TracebackType @@ -862,6 +863,8 @@ def _idempotency_key(self) -> str: return f"stainless-python-retry-{uuid.uuid4()}" +_no_proxy_sanitizer_lock = threading.Lock() + @contextlib.contextmanager def _sanitized_no_proxy() -> Iterator[None]: @@ -876,21 +879,26 @@ def _sanitized_no_proxy() -> Iterator[None]: sanitized value to be visible for that window and then restore the original afterwards — this avoids permanently mutating process-global state for unrelated clients. + + A module-level lock serializes concurrent client constructions so that one + call cannot restore the original (invalid) value while another call's + ``super().__init__()`` is still reading the environment. """ - originals: dict[str, str] = {} - try: - for key in ("NO_PROXY", "no_proxy"): - val = os.environ.get(key) - if val and any(c in val for c in "\n\r"): - originals[key] = val - # splitlines() handles \n, \r, \r\n, and other Unicode line - # separators uniformly. - parts = [part.strip() for part in val.splitlines()] - os.environ[key] = ",".join(p for p in parts if p) - yield - finally: - for key, val in originals.items(): - os.environ[key] = val + with _no_proxy_sanitizer_lock: + originals: dict[str, str] = {} + try: + for key in ("NO_PROXY", "no_proxy"): + val = os.environ.get(key) + if val and any(c in val for c in "\n\r"): + originals[key] = val + # splitlines() handles \n, \r, \r\n, and other Unicode line + # separators uniformly. + parts = [part.strip() for part in val.splitlines()] + os.environ[key] = ",".join(p for p in parts if p) + yield + finally: + for key, val in originals.items(): + os.environ[key] = val class _DefaultHttpxClient(httpx2.Client): diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py index b3f2bd1e42..93fe7a99ae 100644 --- a/tests/test_no_proxy_sanitize.py +++ b/tests/test_no_proxy_sanitize.py @@ -7,6 +7,8 @@ from __future__ import annotations +import os + import pytest @@ -197,3 +199,36 @@ def test_aiohttp_client_trust_env_false_skips_sanitization(monkeypatch: pytest.M assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" assert client._mounts == {} + + +def test_concurrent_client_construction_serializes_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: + """Concurrent client constructions must not race on the env mutation. + + Without the lock, one call could restore the original (invalid) NO_PROXY + value while another call's ``super().__init__()`` is still reading the + environment, exposing the second client to InvalidURL. The lock + serializes the sanitize-construct-restore window so each call sees a + consistent environment. + """ + import threading + + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + errors: list[Exception] = [] + + def construct() -> None: + try: + _DefaultHttpxClient() + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=construct) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Concurrent constructions failed: {errors}" + # The original (invalid) value must be restored after all constructions + assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" From 3e54a0cd7c6142066eb80a95ff7c480975182206 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Thu, 13 Aug 2026 21:06:12 +0530 Subject: [PATCH 3/8] fix: remove private _mounts access to satisfy strict Pyright The _mounts attribute is private on httpx transports and not visible to Pyright. The os.environ assertion already verifies trust_env=False is respected, so the _mounts check is redundant. --- tests/test_no_proxy_sanitize.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py index 93fe7a99ae..d0cb6a06f7 100644 --- a/tests/test_no_proxy_sanitize.py +++ b/tests/test_no_proxy_sanitize.py @@ -194,11 +194,10 @@ def test_aiohttp_client_trust_env_false_skips_sanitization(monkeypatch: pytest.M from openai._base_client import _DefaultAioHttpClient _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") - client = _DefaultAioHttpClient(trust_env=False) + _DefaultAioHttpClient(trust_env=False) import os assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" - assert client._mounts == {} def test_concurrent_client_construction_serializes_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: From c9858899c587510ef2b51449f31eb0dfd6d60ead Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Tue, 25 Aug 2026 14:29:20 +0530 Subject: [PATCH 4/8] fix: preserve concurrent NO_PROXY updates and restore inherited env in tests Codex P2 review (Aug 22): 1. The sanitizer's restore clobbered NO_PROXY updates made by application code while a client was being constructed. Restore now only fires when the current value is still the one we sanitized. 2. Tests could leak a mutated NO_PROXY into the next test when a construction failed mid-way. An autouse fixture snapshots the inherited NO_PROXY/no_proxy and restores them after each test. Regression test: test_concurrent_no_proxy_update_not_clobbered. --- src/openai/_base_client.py | 11 +++++++-- tests/test_no_proxy_sanitize.py | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index c1d01a99f2..13b9115070 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -886,6 +886,7 @@ def _sanitized_no_proxy() -> Iterator[None]: """ with _no_proxy_sanitizer_lock: originals: dict[str, str] = {} + sanitized: dict[str, str] = {} try: for key in ("NO_PROXY", "no_proxy"): val = os.environ.get(key) @@ -894,11 +895,17 @@ def _sanitized_no_proxy() -> Iterator[None]: # splitlines() handles \n, \r, \r\n, and other Unicode line # separators uniformly. parts = [part.strip() for part in val.splitlines()] - os.environ[key] = ",".join(p for p in parts if p) + sanitized[key] = ",".join(p for p in parts if p) + os.environ[key] = sanitized[key] yield finally: for key, val in originals.items(): - os.environ[key] = val + # Only restore if the value is still the one we sanitized — + # application code may have updated NO_PROXY while the client + # was being constructed, and clobbering that update would be + # worse than leaving the sanitized value in place. + if os.environ.get(key) == sanitized.get(key): + os.environ[key] = val class _DefaultHttpxClient(httpx2.Client): diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py index d0cb6a06f7..e865ba15e2 100644 --- a/tests/test_no_proxy_sanitize.py +++ b/tests/test_no_proxy_sanitize.py @@ -12,6 +12,28 @@ import pytest +@pytest.fixture(autouse=True) +def _restore_inherited_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """Restore any NO_PROXY/no_proxy inherited from the test environment. + + The sanitizer mutates os.environ during client construction and restores + it afterwards, but a test that fails mid-construction (or a test that + deliberately leaves a value set) can leak into the next test. Snapshot the + inherited values up front and restore them after each test so the suite + is deterministic regardless of the host environment. + """ + inherited = { + key: os.environ.get(key) + for key in ("NO_PROXY", "no_proxy") + } + yield + for key, val in inherited.items(): + if val is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, val) + + def _set_no_proxy(monkeypatch: pytest.MonkeyPatch, value: str | None) -> None: """Set both NO_PROXY and no_proxy via monkeypatch for automatic cleanup.""" if value is None: @@ -231,3 +253,23 @@ def construct() -> None: assert not errors, f"Concurrent constructions failed: {errors}" # The original (invalid) value must be restored after all constructions assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" + + +def test_concurrent_no_proxy_update_not_clobbered(monkeypatch: pytest.MonkeyPatch) -> None: + """An application update to NO_PROXY during construction is preserved. + + If application code changes NO_PROXY while a client is being constructed, + the sanitizer must not clobber that update when it restores the original + value — the update is newer and more relevant than the pre-construction + value. + """ + from openai._base_client import _DefaultHttpxClient, _sanitized_no_proxy + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + + with _sanitized_no_proxy(): + # Simulate application code updating NO_PROXY mid-construction. + os.environ["NO_PROXY"] = "api.example.com" + + # The application's update must survive the sanitizer's restore. + assert os.environ.get("NO_PROXY") == "api.example.com" From aeb77b50f63624c36555abf8fbdb847a2a7efd87 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Tue, 25 Aug 2026 14:38:47 +0530 Subject: [PATCH 5/8] fix: drop unused import in concurrent-update regression test --- tests/test_no_proxy_sanitize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py index e865ba15e2..23305050df 100644 --- a/tests/test_no_proxy_sanitize.py +++ b/tests/test_no_proxy_sanitize.py @@ -263,7 +263,7 @@ def test_concurrent_no_proxy_update_not_clobbered(monkeypatch: pytest.MonkeyPatc value — the update is newer and more relevant than the pre-construction value. """ - from openai._base_client import _DefaultHttpxClient, _sanitized_no_proxy + from openai._base_client import _sanitized_no_proxy _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") From d433a18a638f75b4e4013e6723d588a7c8f87b97 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Tue, 25 Aug 2026 14:45:14 +0530 Subject: [PATCH 6/8] fix: type the autouse env-restore fixture for strict pyright --- tests/test_no_proxy_sanitize.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py index 23305050df..e2e32ea56f 100644 --- a/tests/test_no_proxy_sanitize.py +++ b/tests/test_no_proxy_sanitize.py @@ -8,12 +8,13 @@ from __future__ import annotations import os +from collections.abc import Iterator import pytest @pytest.fixture(autouse=True) -def _restore_inherited_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: +def _restore_inherited_no_proxy(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: # pyright: ignore[reportUnusedFunction] """Restore any NO_PROXY/no_proxy inherited from the test environment. The sanitizer mutates os.environ during client construction and restores From fb5a3a31ab725362eef094130e0d91debff02be3 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Fri, 4 Sep 2026 08:18:15 +0530 Subject: [PATCH 7/8] fix: return Generator from the NO_PROXY contextmanager for strict pyright Pyright's reportDeprecated flags @contextmanager decorated functions annotated -> Iterator[...]: the decorator returns a Generator, so type the return as Generator[None, None, None] instead. Clears the lint failure that reds the branch CI (src/openai/_base_client.py:869 reportDeprecated). --- src/openai/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 13b9115070..4163f84eef 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -867,7 +867,7 @@ def _idempotency_key(self) -> str: @contextlib.contextmanager -def _sanitized_no_proxy() -> Iterator[None]: +def _sanitized_no_proxy() -> Generator[None, None, None]: """Temporarily normalize line separators in NO_PROXY/no_proxy for the duration of a httpx client construction. From ca7116bf4ec1a6175f82d2ea053ae9ac80af23a7 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Wed, 9 Sep 2026 17:01:46 +0530 Subject: [PATCH 8/8] fix: fork-safe NO_PROXY lock, all splitlines separators, httpx2 factory coverage --- src/openai/_base_client.py | 41 ++++++++++----- src/openai/_httpx2.py | 14 ++++++ tests/test_no_proxy_sanitize.py | 88 ++++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 4163f84eef..cd2ab5d31c 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -866,19 +866,35 @@ def _idempotency_key(self) -> str: _no_proxy_sanitizer_lock = threading.Lock() +def _reset_no_proxy_sanitizer_lock() -> None: + """Replace the sanitizer lock after a fork. + + If a POSIX process forks while another thread holds the lock, the child + inherits the lock in the acquired state but not the thread that can + release it, so any later client construction in the child would block + forever. ``os.register_at_fork`` replaces the lock in the child. + """ + global _no_proxy_sanitizer_lock + _no_proxy_sanitizer_lock = threading.Lock() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_no_proxy_sanitizer_lock) + + @contextlib.contextmanager def _sanitized_no_proxy() -> Generator[None, None, None]: """Temporarily normalize line separators in NO_PROXY/no_proxy for the duration of a httpx client construction. - httpx's ``get_environment_proxies()`` only splits on commas, so a trailing - newline or carriage return in ``NO_PROXY`` (common in Docker/``.env`` files - or CRLF values where the ``\\n`` was stripped but ``\\r`` remains) becomes - part of the hostname and httpx raises ``InvalidURL`` (issue #3303). httpx - reads the environment once during ``__init__``, so we only need the - sanitized value to be visible for that window and then restore the original - afterwards — this avoids permanently mutating process-global state for - unrelated clients. + httpx's ``get_environment_proxies()`` only splits on commas, so a line + separator in ``NO_PROXY`` (common in Docker/``.env`` files or CRLF values + where the ``\\n`` was stripped but ``\\r`` remains) becomes part of the + hostname and httpx raises ``InvalidURL`` (issue #3303). httpx reads the + environment once during ``__init__``, so we only need the sanitized value + to be visible for that window and then restore the original afterwards — + this avoids permanently mutating process-global state for unrelated + clients. A module-level lock serializes concurrent client constructions so that one call cannot restore the original (invalid) value while another call's @@ -890,10 +906,13 @@ def _sanitized_no_proxy() -> Generator[None, None, None]: try: for key in ("NO_PROXY", "no_proxy"): val = os.environ.get(key) - if val and any(c in val for c in "\n\r"): + # splitlines() recognizes every line boundary (\n, \r, \r\n, + # \v, \f, \x1c-\x1e, \x85, \u2028, \u2029), including a + # trailing separator that produces a single part. If the + # value contains any boundary, the split differs from the + # original string and sanitization is required. + if val and val.splitlines() != [val]: originals[key] = val - # splitlines() handles \n, \r, \r\n, and other Unicode line - # separators uniformly. parts = [part.strip() for part in val.splitlines()] sanitized[key] = ",".join(p for p in parts if p) os.environ[key] = sanitized[key] diff --git a/src/openai/_httpx2.py b/src/openai/_httpx2.py index 491398b43c..e324e5a434 100644 --- a/src/openai/_httpx2.py +++ b/src/openai/_httpx2.py @@ -132,10 +132,24 @@ def _set_httpx2_defaults(kwargs: dict[str, Any]) -> None: def DefaultHttpx2Client(**kwargs: Any) -> httpx2.Client: """Create an HTTPX2 client with the SDK's recommended defaults.""" _set_httpx2_defaults(kwargs) + # Lazy import to avoid a circular dependency: _base_client imports from + # this module, and the sanitizer lives there. + from ._base_client import _sanitized_no_proxy + + if kwargs.get("trust_env", True): + with _sanitized_no_proxy(): + return httpx2.Client(**kwargs) return httpx2.Client(**kwargs) def DefaultAsyncHttpx2Client(**kwargs: Any) -> httpx2.AsyncClient: """Create an async HTTPX2 client with the SDK's recommended defaults.""" _set_httpx2_defaults(kwargs) + # Lazy import to avoid a circular dependency: _base_client imports from + # this module, and the sanitizer lives there. + from ._base_client import _sanitized_no_proxy + + if kwargs.get("trust_env", True): + with _sanitized_no_proxy(): + return httpx2.AsyncClient(**kwargs) return httpx2.AsyncClient(**kwargs) diff --git a/tests/test_no_proxy_sanitize.py b/tests/test_no_proxy_sanitize.py index e2e32ea56f..ed9dff3e8c 100644 --- a/tests/test_no_proxy_sanitize.py +++ b/tests/test_no_proxy_sanitize.py @@ -200,7 +200,7 @@ def test_crlf_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: def test_aiohttp_client_construction_with_newline_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: """The aiohttp transport client also sanitizes NO_PROXY newlines.""" - pytest.importorskip("httpx_aiohttp") + pytest.importorskip("aiohttp") from openai._base_client import _DefaultAioHttpClient _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") @@ -213,7 +213,7 @@ def test_aiohttp_client_construction_with_newline_no_proxy(monkeypatch: pytest.M def test_aiohttp_client_trust_env_false_skips_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: """The aiohttp transport client respects trust_env=False.""" - pytest.importorskip("httpx_aiohttp") + pytest.importorskip("aiohttp") from openai._base_client import _DefaultAioHttpClient _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") @@ -274,3 +274,87 @@ def test_concurrent_no_proxy_update_not_clobbered(monkeypatch: pytest.MonkeyPatc # The application's update must survive the sanitizer's restore. assert os.environ.get("NO_PROXY") == "api.example.com" + + +@pytest.mark.parametrize( + "separator", + ["\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"], +) +def test_all_splitlines_separators_sanitized(monkeypatch: pytest.MonkeyPatch, separator: str) -> None: + """Every line boundary recognized by str.splitlines() is sanitized. + + httpx rejects any non-printable character in the proxy hostname, so a + NO_PROXY value containing a vertical tab, form feed, or Unicode line + separator must be normalized just like a newline. + """ + from openai._base_client import _DefaultHttpxClient + + _set_no_proxy(monkeypatch, f"localhost{separator}127.0.0.1") + client = _DefaultHttpxClient() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + client.close() + + +def test_httpx2_factory_sanitizes_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """The public DefaultHttpx2Client factory also sanitizes NO_PROXY.""" + from openai import DefaultHttpx2Client + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + client = DefaultHttpx2Client() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + client.close() + + +def test_httpx2_factory_trust_env_false_skips_sanitization(monkeypatch: pytest.MonkeyPatch) -> None: + """The public DefaultHttpx2Client factory respects trust_env=False.""" + from openai import DefaultHttpx2Client + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + client = DefaultHttpx2Client(trust_env=False) + assert os.environ.get("NO_PROXY") == "localhost\n127.0.0.1" + assert client._mounts == {} + client.close() + + +def test_async_httpx2_factory_sanitizes_no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + """The public DefaultAsyncHttpx2Client factory also sanitizes NO_PROXY.""" + from openai import DefaultAsyncHttpx2Client + + _set_no_proxy(monkeypatch, "localhost\n127.0.0.1") + client = DefaultAsyncHttpx2Client() + patterns = _mount_patterns(client) + assert any("localhost" in p for p in patterns) + assert any("127.0.0.1" in p for p in patterns) + + +def test_sanitizer_lock_replaced_after_fork() -> None: + """The sanitizer lock is replaced in the child after a fork. + + If a fork happens while another thread holds the lock, the child would + inherit the acquired lock and block forever on the next client + construction. os.register_at_fork must replace it. + """ + import multiprocessing + + import openai._base_client as base_client + + # Simulate the child state: acquire the lock in this process, then fork. + base_client._no_proxy_sanitizer_lock.acquire() + try: + ctx = multiprocessing.get_context("fork") + q = ctx.Queue() + p = ctx.Process( + target=lambda: q.put(base_client._no_proxy_sanitizer_lock.locked()), + ) + p.start() + p.join(timeout=10) + child_locked = q.get(timeout=5) + finally: + base_client._no_proxy_sanitizer_lock.release() + + # The child must not inherit the acquired lock. + assert child_locked is False