Skip to content
90 changes: 87 additions & 3 deletions src/openai/_base_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import sys
import json
import math
Expand All @@ -11,6 +12,8 @@
import logging
import platform
import warnings
import threading
import contextlib
import email.utils
from types import TracebackType
from random import random
Expand Down Expand Up @@ -860,12 +863,83 @@ def _idempotency_key(self) -> str:
return f"stainless-python-retry-{uuid.uuid4()}"


_no_proxy_sanitizer_lock = threading.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the sanitizer lock after fork

When a POSIX process forks while another thread is inside a default-client constructor, the child inherits this lock in the acquired state but not the thread that can release it. Any subsequent OpenAI() or default HTTP client construction in that child then blocks permanently at _sanitized_no_proxy, even when NO_PROXY needs no sanitization. Register an after_in_child fork handler that replaces the lock, or otherwise make the lock process-aware.

Useful? React with 👍 / 👎.



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)
Comment on lines +881 to +882

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore proxy variables in the forked child

When one thread forks while another is inside _sanitized_no_proxy(), the child inherits the temporarily comma-normalized environment, while the thread whose finally block would restore the original values no longer exists there. The new after_in_child callback resets only the lock, so the child—and any process it subsequently execs—permanently receives modified NO_PROXY/no_proxy values. Track the active originals and restore them in the child callback as well as replacing the lock.

Useful? React with 👍 / 👎.



@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 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
``super().__init__()`` is still reading the environment.
"""
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)
# 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
parts = [part.strip() for part in val.splitlines()]
sanitized[key] = ",".join(p for p in parts if p)
os.environ[key] = sanitized[key]
yield
finally:
for key, val in originals.items():
# 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):
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:
Expand Down Expand Up @@ -1459,7 +1533,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]
Expand All @@ -1480,7 +1559,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

Expand Down
14 changes: 14 additions & 0 deletions src/openai/_httpx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading