From 8125b58a85b0cf3ad91cfe305324a820f8d5c30b Mon Sep 17 00:00:00 2001 From: TasadduqB Date: Thu, 11 Jun 2026 15:52:03 +0530 Subject: [PATCH] fix: sanitize NO_PROXY env var newlines before httpx client init When NO_PROXY contains newline characters (common in Docker .env files and shell scripts), httpx's URL parser rejects the non-printable characters with InvalidURL. Since httpx is not accepting external fixes, sanitize NO_PROXY/no_proxy by replacing newlines with commas before httpx reads the environment. Closes #3303 --- src/openai/_base_client.py | 20 ++++++++- tests/test_client.py | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 216b36aabd..692af116b3 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 time @@ -831,11 +832,27 @@ def _idempotency_key(self) -> str: return f"stainless-python-retry-{uuid.uuid4()}" +def _sanitize_proxy_env() -> None: + """Sanitize proxy-related environment variables that may contain newline characters. + + Some environments (Docker .env files, shell scripts) introduce newline characters + into NO_PROXY / no_proxy values. httpx only splits these by comma, so newlines + end up embedded in hostnames and trigger ``httpx.InvalidURL``. + """ + for key in ("NO_PROXY", "no_proxy"): + value = os.environ.get(key) + if value and "\n" in value: + os.environ[key] = ",".join( + part.strip() for part in value.replace("\n", ",").split(",") if part.strip() + ) + + class _DefaultHttpxClient(httpx.Client): def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) + _sanitize_proxy_env() super().__init__(**kwargs) @@ -1423,6 +1440,7 @@ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) + _sanitize_proxy_env() super().__init__(**kwargs) @@ -1440,7 +1458,7 @@ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - + _sanitize_proxy_env() super().__init__(**kwargs) diff --git a/tests/test_client.py b/tests/test_client.py index 2d8955a58e..cd187c58e6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1292,6 +1292,48 @@ def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> N assert len(mounts) == 1 assert mounts[0][0].pattern == "https://" + def test_no_proxy_newline_sanitized(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "localhost\n192.168.1.1") + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + client = DefaultHttpxClient() + assert client is not None + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + def test_no_proxy_lowercase_newline_sanitized(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("no_proxy", "localhost\n192.168.1.1\n10.0.0.0/8") + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + + client = DefaultHttpxClient() + assert client is not None + assert os.environ["no_proxy"] == "localhost,192.168.1.1,10.0.0.0/8" + + def test_no_proxy_without_newlines_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "localhost,192.168.1.1") + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + client = DefaultHttpxClient() + assert client is not None + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") def test_default_client_creation(self) -> None: # Ensure that the client can be initialized without any exceptions @@ -2552,6 +2594,48 @@ async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch assert len(mounts) == 1 assert mounts[0][0].pattern == "https://" + async def test_no_proxy_newline_sanitized(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "localhost\n192.168.1.1") + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + client = DefaultAsyncHttpxClient() + assert client is not None + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + async def test_no_proxy_lowercase_newline_sanitized(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("no_proxy", "localhost\n192.168.1.1\n10.0.0.0/8") + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + + client = DefaultAsyncHttpxClient() + assert client is not None + assert os.environ["no_proxy"] == "localhost,192.168.1.1,10.0.0.0/8" + + async def test_no_proxy_without_newlines_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "localhost,192.168.1.1") + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + client = DefaultAsyncHttpxClient() + assert client is not None + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") async def test_default_client_creation(self) -> None: # Ensure that the client can be initialized without any exceptions