Skip to content

Commit 359113a

Browse files
committed
fix: sanitize newlines in NO_PROXY env var before httpx client init
Docker/.env files can leave newline or carriage return characters in NO_PROXY. httpx splits the value on commas only, so a trailing newline or CR becomes part of the hostname and httpx raises InvalidURL (issue #3303). The fix normalizes line separators in NO_PROXY/no_proxy to commas before httpx reads the environment during client construction. The sanitization is temporary: the original env value is saved before and restored after httpx init completes, so the process environment is not permanently mutated. A threading lock prevents concurrent client constructions from seeing a partially sanitized value. When trust_env=False is explicitly passed, httpx does not read proxy environment variables at all, so the sanitization is skipped entirely — no side effect on the process environment for users who opt out of env-based proxy configuration. Closes #3303
1 parent e43b422 commit 359113a

2 files changed

Lines changed: 221 additions & 2 deletions

File tree

src/openai/_base_client.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import os
34
import sys
45
import json
56
import math
@@ -11,6 +12,7 @@
1112
import logging
1213
import platform
1314
import warnings
15+
import threading
1416
import email.utils
1517
from types import TracebackType
1618
from random import random
@@ -860,12 +862,72 @@ def _idempotency_key(self) -> str:
860862
return f"stainless-python-retry-{uuid.uuid4()}"
861863

862864

865+
_no_proxy_lock = threading.Lock()
866+
867+
868+
def _sanitize_no_proxy_env(*, trust_env: bool) -> None:
869+
"""Normalize line separators in ``NO_PROXY`` / ``no_proxy`` before httpx reads them.
870+
871+
httpx's ``get_environment_proxies()`` splits the value on commas only, so a
872+
trailing newline or carriage return (common in Docker ``.env`` files or CRLF
873+
values where ``\n`` was stripped but ``\r`` remains) becomes part of the
874+
hostname and httpx raises ``InvalidURL`` (issue #3303).
875+
876+
httpx reads the environment once during ``__init__``, so we normalize
877+
in-place before calling ``super().__init__()``. The caller is responsible
878+
for saving and restoring the original value via ``_save_no_proxy_env`` /
879+
``_restore_no_proxy_env`` so the mutation is temporary.
880+
881+
When ``trust_env=False`` is explicitly passed, httpx will not read proxy
882+
environment variables at all, so we skip the sanitization entirely.
883+
"""
884+
if not trust_env:
885+
return
886+
887+
for var in ("NO_PROXY", "no_proxy"):
888+
raw = os.environ.get(var)
889+
if raw is None:
890+
continue
891+
sanitized = ",".join(part.strip() for part in raw.replace("\r", "\n").split("\n") if part.strip())
892+
if sanitized != raw:
893+
os.environ[var] = sanitized
894+
895+
896+
def _save_no_proxy_env() -> dict[str, str | None]:
897+
"""Snapshot the current NO_PROXY/no_proxy env vars for later restoration."""
898+
return {var: os.environ.get(var) for var in ("NO_PROXY", "no_proxy")}
899+
900+
901+
def _restore_no_proxy_env(saved: dict[str, str | None]) -> None:
902+
"""Restore NO_PROXY/no_proxy env vars to their pre-sanitization values."""
903+
for var, value in saved.items():
904+
if value is None:
905+
os.environ.pop(var, None)
906+
else:
907+
os.environ[var] = value
908+
909+
863910
class _DefaultHttpxClient(httpx2.Client):
864911
def __init__(self, **kwargs: Any) -> None:
865912
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
866913
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
867914
kwargs.setdefault("follow_redirects", True)
868-
super().__init__(**kwargs)
915+
# Sanitize NO_PROXY before httpx reads the environment, but only when
916+
# trust_env is not explicitly disabled. The lock ensures concurrent
917+
# client constructions don't see a partially sanitized value.
918+
# The original env value is restored after init so the mutation is
919+
# temporary and doesn't affect other code in the same process.
920+
trust_env = kwargs.get("trust_env", True)
921+
if trust_env:
922+
with _no_proxy_lock:
923+
_saved = _save_no_proxy_env()
924+
try:
925+
_sanitize_no_proxy_env(trust_env=trust_env)
926+
super().__init__(**kwargs)
927+
finally:
928+
_restore_no_proxy_env(_saved)
929+
else:
930+
super().__init__(**kwargs)
869931

870932

871933
if TYPE_CHECKING:
@@ -1459,7 +1521,17 @@ def __init__(self, **kwargs: Any) -> None:
14591521
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
14601522
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
14611523
kwargs.setdefault("follow_redirects", True)
1462-
super().__init__(**kwargs)
1524+
trust_env = kwargs.get("trust_env", True)
1525+
if trust_env:
1526+
with _no_proxy_lock:
1527+
_saved = _save_no_proxy_env()
1528+
try:
1529+
_sanitize_no_proxy_env(trust_env=trust_env)
1530+
super().__init__(**kwargs)
1531+
finally:
1532+
_restore_no_proxy_env(_saved)
1533+
else:
1534+
super().__init__(**kwargs)
14631535

14641536

14651537
_DefaultAioHttpClient: type[httpx2.AsyncClient]
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Tests for NO_PROXY env var sanitization during httpx client init.
2+
3+
Regression tests for issue #3303: Docker/.env files can leave newline or
4+
carriage return characters in NO_PROXY, which httpx's comma-only splitter
5+
treats as part of the hostname, causing InvalidURL.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
from typing import Iterator
12+
13+
import pytest
14+
15+
from openai._base_client import (
16+
_save_no_proxy_env,
17+
_DefaultHttpxClient,
18+
_restore_no_proxy_env,
19+
_sanitize_no_proxy_env,
20+
_DefaultAsyncHttpxClient,
21+
)
22+
23+
24+
@pytest.fixture(autouse=True)
25+
def clean_no_proxy() -> Iterator[None]:
26+
"""Remove NO_PROXY/no_proxy before and after each test."""
27+
for var in ("NO_PROXY", "no_proxy"):
28+
os.environ.pop(var, None)
29+
yield
30+
for var in ("NO_PROXY", "no_proxy"):
31+
os.environ.pop(var, None)
32+
33+
34+
class TestSanitizeNoProxyEnv:
35+
def test_newlines_replaced_with_commas(self) -> None:
36+
os.environ["NO_PROXY"] = "localhost\n127.0.0.1\n.example.com"
37+
_sanitize_no_proxy_env(trust_env=True)
38+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1,.example.com"
39+
40+
def test_carriage_returns_replaced(self) -> None:
41+
os.environ["NO_PROXY"] = "localhost\r127.0.0.1"
42+
_sanitize_no_proxy_env(trust_env=True)
43+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
44+
45+
def test_crlf_handled(self) -> None:
46+
os.environ["NO_PROXY"] = "localhost\r\n127.0.0.1\r\n"
47+
_sanitize_no_proxy_env(trust_env=True)
48+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
49+
50+
def test_trailing_newline_stripped(self) -> None:
51+
os.environ["NO_PROXY"] = "localhost,127.0.0.1\n"
52+
_sanitize_no_proxy_env(trust_env=True)
53+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
54+
55+
def test_already_clean_value_unchanged(self) -> None:
56+
os.environ["NO_PROXY"] = "localhost,127.0.0.1"
57+
_sanitize_no_proxy_env(trust_env=True)
58+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
59+
60+
def test_no_env_var_unchanged(self) -> None:
61+
_sanitize_no_proxy_env(trust_env=True)
62+
assert "NO_PROXY" not in os.environ
63+
assert "no_proxy" not in os.environ
64+
65+
def test_lowercase_var_sanitized(self) -> None:
66+
os.environ["no_proxy"] = "localhost\n127.0.0.1"
67+
_sanitize_no_proxy_env(trust_env=True)
68+
assert os.environ["no_proxy"] == "localhost,127.0.0.1"
69+
70+
def test_trust_env_false_skips_sanitization(self) -> None:
71+
os.environ["NO_PROXY"] = "localhost\n127.0.0.1"
72+
_sanitize_no_proxy_env(trust_env=False)
73+
# Should remain unchanged when trust_env=False
74+
assert os.environ["NO_PROXY"] == "localhost\n127.0.0.1"
75+
76+
def test_empty_lines_skipped(self) -> None:
77+
os.environ["NO_PROXY"] = "localhost\n\n127.0.0.1\n"
78+
_sanitize_no_proxy_env(trust_env=True)
79+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
80+
81+
def test_whitespace_stripped(self) -> None:
82+
os.environ["NO_PROXY"] = " localhost \n 127.0.0.1 "
83+
_sanitize_no_proxy_env(trust_env=True)
84+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
85+
86+
87+
class TestSaveRestoreNoProxyEnv:
88+
def test_save_and_restore_unchanged(self) -> None:
89+
os.environ["NO_PROXY"] = "localhost,127.0.0.1"
90+
saved = _save_no_proxy_env()
91+
_sanitize_no_proxy_env(trust_env=True)
92+
_restore_no_proxy_env(saved)
93+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
94+
95+
def test_save_and_restore_with_newlines(self) -> None:
96+
os.environ["NO_PROXY"] = "localhost\n127.0.0.1"
97+
saved = _save_no_proxy_env()
98+
_sanitize_no_proxy_env(trust_env=True)
99+
# After sanitization, the env is changed
100+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"
101+
# After restore, the original value is back
102+
_restore_no_proxy_env(saved)
103+
assert os.environ["NO_PROXY"] == "localhost\n127.0.0.1"
104+
105+
def test_save_and_restore_missing_var(self) -> None:
106+
saved = _save_no_proxy_env()
107+
os.environ["NO_PROXY"] = "should-be-removed"
108+
_restore_no_proxy_env(saved)
109+
assert "NO_PROXY" not in os.environ
110+
111+
112+
class TestClientConstruction:
113+
def test_sync_client_restores_env_after_init(self) -> None:
114+
"""The sync client should restore the original NO_PROXY value after init."""
115+
original = "localhost\n127.0.0.1"
116+
os.environ["NO_PROXY"] = original
117+
client = _DefaultHttpxClient()
118+
client.close()
119+
# The original (unsanitized) value should be restored
120+
assert os.environ["NO_PROXY"] == original
121+
122+
def test_async_client_restores_env_after_init(self) -> None:
123+
"""The async client should restore the original NO_PROXY value after init."""
124+
original = "localhost\n127.0.0.1"
125+
os.environ["NO_PROXY"] = original
126+
client = _DefaultAsyncHttpxClient()
127+
# Close the client to clean up
128+
import asyncio
129+
130+
asyncio.run(client.aclose())
131+
# The original (unsanitized) value should be restored
132+
assert os.environ["NO_PROXY"] == original
133+
134+
def test_sync_client_with_trust_env_false_no_mutation(self) -> None:
135+
"""When trust_env=False, NO_PROXY should not be touched at all."""
136+
original = "localhost\n127.0.0.1"
137+
os.environ["NO_PROXY"] = original
138+
client = _DefaultHttpxClient(trust_env=False)
139+
client.close()
140+
assert os.environ["NO_PROXY"] == original
141+
142+
def test_sync_client_with_clean_no_proxy(self) -> None:
143+
"""A clean NO_PROXY value should work fine with the sync client."""
144+
os.environ["NO_PROXY"] = "localhost,127.0.0.1"
145+
client = _DefaultHttpxClient()
146+
client.close()
147+
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1"

0 commit comments

Comments
 (0)