mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
feat(openai): prevent silent streaming hangs in ChatOpenAI (#36949)
> [!IMPORTANT] > **Behavior change on upgrade — minor bump (`1.1.16` → `1.2.0`).** > > Streaming calls now raise `StreamChunkTimeoutError` (a `TimeoutError` subclass — existing `except TimeoutError:` / `except asyncio.TimeoutError:` handlers catch it) after 120s of content silence instead of hanging forever. Opt out with `stream_chunk_timeout=None` or `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S=0`. > > Kernel-level TCP keepalive / `TCP_USER_TIMEOUT` are applied via a custom `httpx` transport. `httpx` disables its env-proxy auto-detection (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` and macOS/Windows system proxy) whenever a transport is supplied, so to avoid silently breaking enterprise proxy users, `ChatOpenAI` now detects the "proxy-env-shadow" shape at construction and **skips the custom transport entirely** when **all** of these hold: > > - `http_socket_options` left at default (`None`) > - No `http_client` or `http_async_client` supplied > - No `openai_proxy` supplied > - A proxy env var / system proxy is visible to httpx > > On that shape the instance falls back to pre-PR behavior and env-proxy auto-detection still applies. A one-time `INFO` records the bypass. > > Users who explicitly set `http_socket_options=[...]` alongside an env proxy still get the shadowed behavior with a one-time `WARNING` log — they opted in. Full opt-outs below. --- Streaming chat completions can hang forever when the underlying TCP connection silently dies mid-stream (idle NAT/LB timeouts, sandboxed runtimes killing long-lived connections, peer gone without a FIN or RST). httpx's read timeout doesn't help here because it's reset by any bytes arriving on the socket, including OpenAI's SSE keepalive comments, so a stream that's quiet on content but still producing keepalives looks alive forever. This PR adds two knobs to `ChatOpenAI`, both on by default with opt-outs: - `stream_chunk_timeout` (default 120s): wraps the async streaming iterator in `asyncio.wait_for` per chunk. Measures the gap between *parsed* SSE chunks, so keepalives don't reset it. Fires on genuine content silence and raises `StreamChunkTimeoutError` — a `TimeoutError` subclass carrying `timeout_s`, `model_name`, and `chunks_received` as structured attributes (mirrored in the WARNING log's `extra=`) for alerting without message-regex. Override with the kwarg or `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S`. - `http_socket_options`: applies `SO_KEEPALIVE` + `TCP_KEEPIDLE` / `TCP_KEEPINTVL` / `TCP_KEEPCNT` + `TCP_USER_TIMEOUT` on Linux (macOS equivalents where available). On platforms missing some options, they're dropped silently and the remaining set still does useful work. Pool limits are set explicitly on the custom transport to mirror the `openai` SDK — without that, passing `transport=` to `httpx.AsyncClient` silently shrinks the connection pool. ## Behavior change The default-shape proxy-env bypass (above) covers the common enterprise case. Beyond that: - Connections that would previously have hung forever will now error out via `StreamChunkTimeoutError`. - Users who explicitly opt into `http_socket_options` while also relying on env proxies will see a one-time `WARNING` and lose env-proxy auto-detection — the custom transport shadows it. This is the original shipped behavior, retained for anyone who *wants* socket tuning on top of an env-proxied setup. Full opt-outs: - `stream_chunk_timeout=None` or `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S=0` - `http_socket_options=()` or `LANGCHAIN_OPENAI_TCP_KEEPALIVE=0` - Supply your own `http_client` **and** `http_async_client`. `http_socket_options` is applied per side: passing only one still leaves the other side's default builder getting socket options. Supply both (or combine with `http_socket_options=()`) to take full control. Unparseable or negative values for the `LANGCHAIN_OPENAI_*` env vars fall back to the default with a `WARNING` log rather than silently being accepted, so a misconfigured environment still boots but the fallback is discoverable. --------- Co-authored-by: Mason Daugherty <github@mdrxy.com> Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
"""Unit tests for `langchain_openai.chat_models._client_utils`.
|
||||
|
||||
Asserts socket-options plumbing at the boundary between our helpers and the
|
||||
httpx layer — not on httpx internals. Locks the wiring, env-driven defaults,
|
||||
the `()` kill-switch contract, and the precedence between constructor kwargs,
|
||||
env vars, and user-supplied clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_openai.chat_models import _client_utils
|
||||
|
||||
SOL_SOCKET = socket.SOL_SOCKET
|
||||
SO_KEEPALIVE = socket.SO_KEEPALIVE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_langchain_openai_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure LANGCHAIN_OPENAI_* env vars don't leak between tests."""
|
||||
for name in list(os.environ):
|
||||
if name.startswith("LANGCHAIN_OPENAI_") or name == "OPENAI_API_KEY":
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
__import__("sys").platform != "linux",
|
||||
reason="Default option set is platform-specific; Linux values asserted here.",
|
||||
)
|
||||
def test_default_socket_options_linux() -> None:
|
||||
"""On Linux, the full option set should be present with default values."""
|
||||
opts = _client_utils._default_socket_options()
|
||||
expected = {
|
||||
(SOL_SOCKET, SO_KEEPALIVE, 1),
|
||||
(socket.IPPROTO_TCP, _client_utils._LINUX_TCP_KEEPIDLE, 60),
|
||||
(socket.IPPROTO_TCP, _client_utils._LINUX_TCP_KEEPINTVL, 10),
|
||||
(socket.IPPROTO_TCP, _client_utils._LINUX_TCP_KEEPCNT, 3),
|
||||
(socket.IPPROTO_TCP, _client_utils._LINUX_TCP_USER_TIMEOUT, 120000),
|
||||
}
|
||||
assert set(opts) == expected
|
||||
|
||||
|
||||
def test_default_socket_options_disabled_returns_empty_tuple(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Kill-switch: `()` is the single 'no options' shape, never None."""
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPALIVE", "0")
|
||||
opts = _client_utils._default_socket_options()
|
||||
assert opts == ()
|
||||
assert isinstance(opts, tuple)
|
||||
|
||||
|
||||
@pytest.mark.enable_socket
|
||||
def test_filter_supported_drops_unsupported() -> None:
|
||||
"""An option with a deliberately-bogus level should be silently dropped.
|
||||
|
||||
Requires a real probe socket, so opt out of the suite-wide
|
||||
`--disable-socket`. If the probe still cannot be created (unusual
|
||||
sandboxed runner), the helper falls back to pass-through; assert that
|
||||
contract explicitly rather than masking the behavior.
|
||||
"""
|
||||
good = (SOL_SOCKET, SO_KEEPALIVE, 1)
|
||||
# Very high level number the kernel will reject.
|
||||
bogus = (0xDEAD, 0xBEEF, 1)
|
||||
try:
|
||||
socket.socket(socket.AF_INET, socket.SOCK_STREAM).close()
|
||||
except OSError:
|
||||
pytest.skip("probe socket unavailable in this environment")
|
||||
result = _client_utils._filter_supported([good, bogus])
|
||||
assert good in result
|
||||
assert bogus not in result
|
||||
|
||||
|
||||
def test_build_async_httpx_client_boundary_kwargs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Did our helper decide to inject a transport or not?"""
|
||||
recorded: list[dict[str, Any]] = []
|
||||
|
||||
original = _client_utils._AsyncHttpxClientWrapper.__init__
|
||||
|
||||
def spy(self: Any, **kwargs: Any) -> None:
|
||||
recorded.append(kwargs)
|
||||
original(self, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_client_utils._AsyncHttpxClientWrapper, "__init__", spy)
|
||||
|
||||
_client_utils._build_async_httpx_client(
|
||||
base_url=None,
|
||||
timeout=None,
|
||||
socket_options=((SOL_SOCKET, SO_KEEPALIVE, 1),),
|
||||
)
|
||||
assert recorded, "expected one call when socket_options populated"
|
||||
assert "transport" in recorded[-1]
|
||||
|
||||
recorded.clear()
|
||||
_client_utils._build_async_httpx_client(
|
||||
base_url=None, timeout=None, socket_options=()
|
||||
)
|
||||
assert recorded, "expected one call when socket_options empty"
|
||||
assert "transport" not in recorded[-1]
|
||||
|
||||
|
||||
def test_build_async_httpx_client_transport_carries_socket_options(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Transport should receive our options + the mirrored limits."""
|
||||
recorded: list[dict[str, Any]] = []
|
||||
|
||||
original_cls = _client_utils.httpx.AsyncHTTPTransport
|
||||
|
||||
class Recorder(original_cls): # type: ignore[misc, valid-type]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
recorded.append(kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models._client_utils.httpx.AsyncHTTPTransport",
|
||||
Recorder,
|
||||
)
|
||||
|
||||
_client_utils._build_async_httpx_client(
|
||||
base_url=None,
|
||||
timeout=None,
|
||||
socket_options=((SOL_SOCKET, SO_KEEPALIVE, 1),),
|
||||
)
|
||||
|
||||
assert recorded, "expected httpx.AsyncHTTPTransport to be constructed"
|
||||
kwargs = recorded[-1]
|
||||
assert kwargs.get("socket_options") == [(SOL_SOCKET, SO_KEEPALIVE, 1)]
|
||||
assert kwargs.get("limits") is _client_utils._DEFAULT_CONNECTION_LIMITS
|
||||
|
||||
|
||||
def test_http_socket_options_none_vs_empty_tuple_vs_populated(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Discriminates the three input shapes at the builder boundary.
|
||||
|
||||
Also locks the no-filter contract for user overrides: the populated-case
|
||||
assertion is verbatim, proving `_resolve_socket_options` does not run
|
||||
user overrides through `_filter_supported`.
|
||||
"""
|
||||
recorded: list[tuple[str, tuple, tuple]] = []
|
||||
|
||||
def spy_async(
|
||||
base_url: str | None,
|
||||
timeout: Any,
|
||||
socket_options: tuple = (),
|
||||
) -> Any:
|
||||
recorded.append(("async", (base_url, timeout), tuple(socket_options)))
|
||||
# Return a real (but unused) client so init completes.
|
||||
return _client_utils._AsyncHttpxClientWrapper(
|
||||
base_url=base_url or "https://api.openai.com/v1", timeout=timeout
|
||||
)
|
||||
|
||||
def spy_sync(
|
||||
base_url: str | None,
|
||||
timeout: Any,
|
||||
socket_options: tuple = (),
|
||||
) -> Any:
|
||||
recorded.append(("sync", (base_url, timeout), tuple(socket_options)))
|
||||
return _client_utils._SyncHttpxClientWrapper(
|
||||
base_url=base_url or "https://api.openai.com/v1", timeout=timeout
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models.base._get_default_async_httpx_client",
|
||||
spy_async,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models.base._get_default_httpx_client",
|
||||
spy_sync,
|
||||
)
|
||||
|
||||
# (1) Unset -> None -> env-driven defaults (non-empty on linux/darwin CI).
|
||||
ChatOpenAI(model="gpt-4o")
|
||||
assert recorded, "expected a default-client build"
|
||||
_, _, opts1 = recorded[-1]
|
||||
assert isinstance(opts1, tuple)
|
||||
|
||||
# (2) Explicit empty tuple -> ().
|
||||
recorded.clear()
|
||||
ChatOpenAI(model="gpt-4o", http_socket_options=())
|
||||
assert recorded
|
||||
assert all(opts == () for _, _, opts in recorded)
|
||||
|
||||
# (3) Populated sequence -> verbatim passthrough (not filtered).
|
||||
recorded.clear()
|
||||
ChatOpenAI(
|
||||
model="gpt-4o",
|
||||
http_socket_options=[(SOL_SOCKET, SO_KEEPALIVE, 1)],
|
||||
)
|
||||
assert recorded
|
||||
for _, _, opts in recorded:
|
||||
assert opts == ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
|
||||
|
||||
def test_openai_proxy_branch_applies_socket_options(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`openai_proxy` path must go through the socket-options-aware proxied helper."""
|
||||
recorded: list[dict[str, Any]] = []
|
||||
|
||||
def spy(proxy: str, verify: Any, socket_options: tuple = ()) -> httpx.AsyncClient:
|
||||
recorded.append(
|
||||
{"proxy": proxy, "verify": verify, "socket_options": socket_options}
|
||||
)
|
||||
return httpx.AsyncClient()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models.base._build_proxied_async_httpx_client",
|
||||
spy,
|
||||
)
|
||||
# Sync branch should also be covered — spy on that too.
|
||||
sync_recorded: list[dict[str, Any]] = []
|
||||
|
||||
def sync_spy(proxy: str, verify: Any, socket_options: tuple = ()) -> httpx.Client:
|
||||
sync_recorded.append(
|
||||
{"proxy": proxy, "verify": verify, "socket_options": socket_options}
|
||||
)
|
||||
return httpx.Client()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models.base._build_proxied_sync_httpx_client",
|
||||
sync_spy,
|
||||
)
|
||||
|
||||
ChatOpenAI(
|
||||
model="gpt-4o",
|
||||
openai_proxy="http://proxy.example.com:3128",
|
||||
http_socket_options=[(SOL_SOCKET, SO_KEEPALIVE, 1)],
|
||||
)
|
||||
|
||||
assert recorded, "expected async proxied helper to be called"
|
||||
assert recorded[-1]["proxy"] == "http://proxy.example.com:3128"
|
||||
assert recorded[-1]["socket_options"] == ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
|
||||
assert sync_recorded, "expected sync proxied helper to be called"
|
||||
assert sync_recorded[-1]["proxy"] == "http://proxy.example.com:3128"
|
||||
assert sync_recorded[-1]["socket_options"] == ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
|
||||
|
||||
def test_user_supplied_http_async_client_untouched(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If the user passes an http_async_client, we must not mutate it."""
|
||||
default_calls: list[Any] = []
|
||||
proxied_calls: list[Any] = []
|
||||
|
||||
def default_async_spy(*args: Any, **kwargs: Any) -> Any:
|
||||
default_calls.append((args, kwargs))
|
||||
msg = "default async builder should not run"
|
||||
raise AssertionError(msg)
|
||||
|
||||
def proxied_async_spy(*args: Any, **kwargs: Any) -> Any:
|
||||
proxied_calls.append((args, kwargs))
|
||||
msg = "proxied async builder should not run"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models.base._get_default_async_httpx_client",
|
||||
default_async_spy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"langchain_openai.chat_models.base._build_proxied_async_httpx_client",
|
||||
proxied_async_spy,
|
||||
)
|
||||
|
||||
user_client = httpx.AsyncClient()
|
||||
user_sync_client = httpx.Client()
|
||||
|
||||
model = ChatOpenAI(
|
||||
model="gpt-4o",
|
||||
http_client=user_sync_client,
|
||||
http_async_client=user_client,
|
||||
http_socket_options=[(SOL_SOCKET, SO_KEEPALIVE, 1)],
|
||||
)
|
||||
|
||||
assert default_calls == []
|
||||
assert proxied_calls == []
|
||||
assert model.http_async_client is user_client
|
||||
assert model.http_client is user_sync_client
|
||||
|
||||
|
||||
def test_default_path_opt_out_is_strict_noop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With LANGCHAIN_OPENAI_TCP_KEEPALIVE=0 we inject no transport.
|
||||
|
||||
Boundary assertion on `_AsyncHttpxClientWrapper.__init__` kwargs — our
|
||||
helper passed nothing, so httpx falls back to its own native behavior
|
||||
(env-proxy handling, pool defaults, trust_env, etc.) completely
|
||||
unaffected by this library.
|
||||
"""
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPALIVE", "0")
|
||||
|
||||
recorded_sync: list[dict[str, Any]] = []
|
||||
recorded_async: list[dict[str, Any]] = []
|
||||
|
||||
sync_original = _client_utils._SyncHttpxClientWrapper.__init__
|
||||
async_original = _client_utils._AsyncHttpxClientWrapper.__init__
|
||||
|
||||
def sync_spy(self: Any, **kwargs: Any) -> None:
|
||||
recorded_sync.append(kwargs)
|
||||
sync_original(self, **kwargs)
|
||||
|
||||
def async_spy(self: Any, **kwargs: Any) -> None:
|
||||
recorded_async.append(kwargs)
|
||||
async_original(self, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_client_utils._SyncHttpxClientWrapper, "__init__", sync_spy)
|
||||
monkeypatch.setattr(_client_utils._AsyncHttpxClientWrapper, "__init__", async_spy)
|
||||
|
||||
ChatOpenAI(model="gpt-4o")
|
||||
|
||||
assert recorded_sync, "expected the sync default client to be built"
|
||||
assert "transport" not in recorded_sync[-1]
|
||||
assert recorded_async, "expected the async default client to be built"
|
||||
assert "transport" not in recorded_async[-1]
|
||||
|
||||
|
||||
def test_invalid_env_values_degrade_safely(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Garbage in LANGCHAIN_OPENAI_TCP_* env vars must not crash model init."""
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPIDLE", "not-an-int")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPINTVL", "")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPCNT", "NaN")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_USER_TIMEOUT_MS", "abc")
|
||||
|
||||
opts = _client_utils._default_socket_options()
|
||||
assert isinstance(opts, tuple)
|
||||
# Fallback values (60/10/3/120000) are used; on Linux, the full option
|
||||
# set should still be present because the fallbacks are valid.
|
||||
# (Windows/darwin may filter some options; at minimum SO_KEEPALIVE
|
||||
# survives.)
|
||||
assert (SOL_SOCKET, SO_KEEPALIVE, 1) in opts
|
||||
|
||||
# Instantiating a model doesn't raise.
|
||||
ChatOpenAI(model="gpt-4o")
|
||||
|
||||
|
||||
def test_invalid_stream_chunk_timeout_env_degrades_safely(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Garbage in LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S must not crash init."""
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", "not-a-float")
|
||||
model = ChatOpenAI(model="gpt-4o")
|
||||
assert model.stream_chunk_timeout == 120.0
|
||||
|
||||
|
||||
def test_default_socket_options_darwin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""macOS: `TCP_USER_TIMEOUT` is unavailable, but keepalive trio maps to darwin."""
|
||||
monkeypatch.setattr(_client_utils.sys, "platform", "darwin")
|
||||
opts = _client_utils._default_socket_options()
|
||||
assert (SOL_SOCKET, SO_KEEPALIVE, 1) in opts
|
||||
darwin_keepalive = (
|
||||
socket.IPPROTO_TCP,
|
||||
_client_utils._DARWIN_TCP_KEEPALIVE,
|
||||
60,
|
||||
)
|
||||
assert darwin_keepalive in opts or opts == ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
|
||||
|
||||
def test_default_socket_options_other_platform(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Unknown platform (e.g. win32): `SO_KEEPALIVE` only."""
|
||||
monkeypatch.setattr(_client_utils.sys, "platform", "win32")
|
||||
opts = _client_utils._default_socket_options()
|
||||
assert opts in (((SOL_SOCKET, SO_KEEPALIVE, 1),), ())
|
||||
|
||||
|
||||
def test_filter_supported_probe_failure_returns_unfiltered(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Contract: probe-socket failure -> input is returned verbatim."""
|
||||
|
||||
def _raise(*args: Any, **kwargs: Any) -> None:
|
||||
msg = "sandboxed"
|
||||
raise OSError(msg)
|
||||
|
||||
monkeypatch.setattr(_client_utils.socket, "socket", _raise)
|
||||
good = (SOL_SOCKET, SO_KEEPALIVE, 1)
|
||||
bogus = (0xDEAD, 0xBEEF, 1)
|
||||
result = _client_utils._filter_supported([good, bogus])
|
||||
assert result == [good, bogus]
|
||||
|
||||
|
||||
def test_invalid_tcp_env_emits_warning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Int env fallback must log a WARNING naming the offending variable."""
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPIDLE", "not-an-int")
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
_client_utils._default_socket_options()
|
||||
assert any(
|
||||
"LANGCHAIN_OPENAI_TCP_KEEPIDLE" in r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def test_negative_tcp_env_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Negative keepalive counts fall back to the default with a WARNING."""
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPCNT", "-5")
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
value = _client_utils._int_env("LANGCHAIN_OPENAI_TCP_KEEPCNT", 3)
|
||||
assert value == 3
|
||||
assert any(
|
||||
"negative" in r.getMessage().lower()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.enable_socket
|
||||
def test_filter_supported_logs_drops_at_debug(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Dropped options are visible at DEBUG so a macOS user can confirm the filter."""
|
||||
try:
|
||||
socket.socket(socket.AF_INET, socket.SOCK_STREAM).close()
|
||||
except OSError:
|
||||
pytest.skip("probe socket unavailable in this environment")
|
||||
caplog.set_level(logging.DEBUG, logger="langchain_openai.chat_models._client_utils")
|
||||
good = (SOL_SOCKET, SO_KEEPALIVE, 1)
|
||||
bogus = (0xDEAD, 0xBEEF, 1)
|
||||
_client_utils._filter_supported([good, bogus])
|
||||
assert any(
|
||||
"Dropped" in r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.DEBUG
|
||||
)
|
||||
|
||||
|
||||
def test_build_proxied_async_httpx_client_opt_out_returns_plain_client() -> None:
|
||||
"""Empty socket_options -> plain httpx.AsyncClient, no transport injection."""
|
||||
client = _client_utils._build_proxied_async_httpx_client(
|
||||
proxy="http://proxy.example:3128",
|
||||
verify=True,
|
||||
socket_options=(),
|
||||
)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
|
||||
def test_build_proxied_async_httpx_client_wraps_transport() -> None:
|
||||
"""Non-empty socket_options -> real httpx.AsyncHTTPTransport wiring executes.
|
||||
|
||||
Exercises the proxy-wrapping bodies end-to-end so a change to httpx's
|
||||
`Proxy`/transport signatures would surface here, not at connect time.
|
||||
"""
|
||||
client = _client_utils._build_proxied_async_httpx_client(
|
||||
proxy="http://proxy.example:3128",
|
||||
verify=True,
|
||||
socket_options=((SOL_SOCKET, SO_KEEPALIVE, 1),),
|
||||
)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
|
||||
def test_build_proxied_sync_httpx_client_opt_out_returns_plain_client() -> None:
|
||||
client = _client_utils._build_proxied_sync_httpx_client(
|
||||
proxy="http://proxy.example:3128",
|
||||
verify=True,
|
||||
socket_options=(),
|
||||
)
|
||||
assert isinstance(client, httpx.Client)
|
||||
|
||||
|
||||
def test_build_proxied_sync_httpx_client_wraps_transport() -> None:
|
||||
client = _client_utils._build_proxied_sync_httpx_client(
|
||||
proxy="http://proxy.example:3128",
|
||||
verify=True,
|
||||
socket_options=((SOL_SOCKET, SO_KEEPALIVE, 1),),
|
||||
)
|
||||
assert isinstance(client, httpx.Client)
|
||||
|
||||
|
||||
def test_warn_if_proxy_env_shadowed_emits_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""One WARNING per process when a proxy env var is shadowed by our transport."""
|
||||
monkeypatch.setenv("HTTP_PROXY", "http://proxy.example:3128")
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_warning_emitted", False)
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
opts = ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
_client_utils._warn_if_proxy_env_shadowed(opts, openai_proxy=None)
|
||||
_client_utils._warn_if_proxy_env_shadowed(opts, openai_proxy=None)
|
||||
warnings = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING and "HTTP_PROXY" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_warn_if_proxy_env_shadowed_detects_lowercase(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Lowercase `http_proxy` is picked up by httpx; the warning must fire for it."""
|
||||
for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("http_proxy", "http://proxy.example:3128")
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_warning_emitted", False)
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
opts = ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
_client_utils._warn_if_proxy_env_shadowed(opts, openai_proxy=None)
|
||||
warnings = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING and "http_proxy" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_warn_if_proxy_env_shadowed_detects_system_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""macOS/Windows system proxies shadow the transport too; warning should fire."""
|
||||
for name in _client_utils._PROXY_ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_warning_emitted", False)
|
||||
monkeypatch.setattr(
|
||||
_client_utils.urllib.request,
|
||||
"getproxies",
|
||||
lambda: {"http": "http://system.proxy:3128"},
|
||||
)
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
opts = ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
_client_utils._warn_if_proxy_env_shadowed(opts, openai_proxy=None)
|
||||
warnings = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING and "system proxy" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_warn_if_proxy_env_shadowed_skipped_when_openai_proxy_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Explicit `openai_proxy` suppresses the warn (proxy handling is controlled)."""
|
||||
monkeypatch.setenv("HTTP_PROXY", "http://proxy.example:3128")
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_warning_emitted", False)
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
opts = ((SOL_SOCKET, SO_KEEPALIVE, 1),)
|
||||
_client_utils._warn_if_proxy_env_shadowed(
|
||||
opts, openai_proxy="http://proxy.example:3128"
|
||||
)
|
||||
assert not [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
|
||||
|
||||
def test_proxy_env_bypass_default_shape_triggers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Default-shape + env proxy => bypass socket-option transport."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
assert _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_env_bypass_no_env_does_not_trigger(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No proxy env/system proxy => no bypass, even with everything else default."""
|
||||
for name in _client_utils._PROXY_ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(_client_utils.urllib.request, "getproxies", dict)
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_env_bypass_blocked_by_explicit_socket_options(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Explicit `http_socket_options` => user opted in, no bypass."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=[(SOL_SOCKET, SO_KEEPALIVE, 1)],
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
# Empty tuple is also an explicit choice (kill-switch), no bypass.
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=(),
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_env_bypass_blocked_by_kill_switch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`LANGCHAIN_OPENAI_TCP_KEEPALIVE=0` => kill-switch owns the disable path."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_TCP_KEEPALIVE", "0")
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_env_bypass_blocked_by_user_http_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Any user-supplied http(_async)_client => user opted in, no bypass."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
user_client = httpx.Client()
|
||||
try:
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=user_client,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
finally:
|
||||
user_client.close()
|
||||
|
||||
async_client = httpx.AsyncClient()
|
||||
try:
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=async_client,
|
||||
openai_proxy=None,
|
||||
)
|
||||
finally:
|
||||
asyncio.run(async_client.aclose())
|
||||
|
||||
|
||||
def test_proxy_env_bypass_blocked_by_openai_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`openai_proxy` handles proxying explicitly => no bypass."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
assert not _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy="http://openai.proxy:3128",
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_env_bypass_detects_lowercase_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Lowercase `https_proxy` also triggers the bypass."""
|
||||
for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("https_proxy", "http://proxy.example:3128")
|
||||
assert _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_env_bypass_detects_system_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""macOS/Windows system proxy config triggers the bypass too."""
|
||||
for name in _client_utils._PROXY_ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(
|
||||
_client_utils.urllib.request,
|
||||
"getproxies",
|
||||
lambda: {"http": "http://system.proxy:3128"},
|
||||
)
|
||||
assert _client_utils._should_bypass_socket_options_for_proxy_env(
|
||||
http_socket_options=None,
|
||||
http_client=None,
|
||||
http_async_client=None,
|
||||
openai_proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_log_proxy_env_bypass_once_emits_info_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""One INFO per process when the bypass kicks in."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_bypass_info_emitted", False)
|
||||
caplog.set_level(logging.INFO, logger="langchain_openai.chat_models._client_utils")
|
||||
_client_utils._log_proxy_env_bypass_once()
|
||||
_client_utils._log_proxy_env_bypass_once()
|
||||
infos = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.INFO and "HTTPS_PROXY" in r.getMessage()
|
||||
]
|
||||
assert len(infos) == 1
|
||||
|
||||
|
||||
def test_client_build_skips_transport_on_proxy_env_default_shape(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end: default-shape ChatOpenAI + HTTPS_PROXY => no custom transport.
|
||||
|
||||
Locks that the bypass wiring in `base.py` actually prevents the default
|
||||
builder from installing `httpx.HTTPTransport(socket_options=...)`. The
|
||||
async client's `_transport` (or underlying mount) should be httpx's
|
||||
default, not ours.
|
||||
"""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
# Neutralise module-level latches so repeated runs still exercise logging.
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_bypass_info_emitted", False)
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_warning_emitted", False)
|
||||
# Clear cached builder results so env changes take effect.
|
||||
_client_utils._cached_sync_httpx_client.cache_clear()
|
||||
_client_utils._cached_async_httpx_client.cache_clear()
|
||||
|
||||
recorded: list[tuple[Any, ...]] = []
|
||||
|
||||
original_build = _client_utils._build_async_httpx_client
|
||||
|
||||
def spy(
|
||||
base_url: str | None,
|
||||
timeout: Any,
|
||||
socket_options: tuple = (),
|
||||
) -> Any:
|
||||
recorded.append(socket_options)
|
||||
return original_build(base_url, timeout, socket_options)
|
||||
|
||||
monkeypatch.setattr(_client_utils, "_build_async_httpx_client", spy)
|
||||
# `_get_default_async_httpx_client` reaches the cached builder directly,
|
||||
# which ignores our module-level patch; bypass the cache to route through
|
||||
# the spy.
|
||||
monkeypatch.setattr(
|
||||
_client_utils,
|
||||
"_cached_async_httpx_client",
|
||||
spy,
|
||||
)
|
||||
|
||||
ChatOpenAI(model="gpt-5.1")
|
||||
|
||||
assert recorded, "async builder should have been called"
|
||||
assert all(opts == () for opts in recorded), (
|
||||
f"expected bypass (no socket options), got {recorded!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_client_build_applies_socket_options_when_user_opts_in(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Explicit `http_socket_options` => transport applied, bypass skipped."""
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_bypass_info_emitted", False)
|
||||
monkeypatch.setattr(_client_utils, "_proxy_env_warning_emitted", False)
|
||||
_client_utils._cached_sync_httpx_client.cache_clear()
|
||||
_client_utils._cached_async_httpx_client.cache_clear()
|
||||
|
||||
recorded: list[tuple[Any, ...]] = []
|
||||
original_build = _client_utils._build_async_httpx_client
|
||||
|
||||
def spy(
|
||||
base_url: str | None,
|
||||
timeout: Any,
|
||||
socket_options: tuple = (),
|
||||
) -> Any:
|
||||
recorded.append(socket_options)
|
||||
return original_build(base_url, timeout, socket_options)
|
||||
|
||||
monkeypatch.setattr(_client_utils, "_build_async_httpx_client", spy)
|
||||
monkeypatch.setattr(_client_utils, "_cached_async_httpx_client", spy)
|
||||
|
||||
explicit = [(SOL_SOCKET, SO_KEEPALIVE, 1)]
|
||||
ChatOpenAI(model="gpt-5.1", http_socket_options=explicit)
|
||||
|
||||
assert recorded, "async builder should have been called"
|
||||
assert all(tuple(opts) == tuple(explicit) for opts in recorded), (
|
||||
f"expected user-supplied opts, got {recorded!r}"
|
||||
)
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Unit tests for `_astream_with_chunk_timeout` and `StreamChunkTimeoutError`.
|
||||
|
||||
- Pass-through when items arrive in time.
|
||||
- Timeout fires with a self-describing message + subclasses TimeoutError.
|
||||
- Structured WARNING log carries `source=stream_chunk_timeout` +
|
||||
`timeout_s` so aggregate logging can distinguish app-layer from
|
||||
transport-layer timeouts.
|
||||
- Source iterator's `aclose()` is called on early exit to release the
|
||||
underlying httpx connection promptly.
|
||||
- Garbage in `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S` degrades safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import TracebackType
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Self
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_openai.chat_models._client_utils import (
|
||||
StreamChunkTimeoutError,
|
||||
_astream_with_chunk_timeout,
|
||||
)
|
||||
|
||||
MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
class _FakeSource:
|
||||
"""AsyncIterator with an observable aclose() for leak-testing."""
|
||||
|
||||
def __init__(self, items: list[Any], per_item_sleep: float = 0.0) -> None:
|
||||
self._items = list(items)
|
||||
self._sleep = per_item_sleep
|
||||
self.aclose_count = 0
|
||||
|
||||
def __aiter__(self) -> _FakeSource:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
if self._sleep:
|
||||
await asyncio.sleep(self._sleep)
|
||||
if not self._items:
|
||||
raise StopAsyncIteration
|
||||
return self._items.pop(0)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.aclose_count += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_with_chunk_timeout_passes_through() -> None:
|
||||
"""Fast source + generous timeout: every item should be delivered."""
|
||||
source = _FakeSource(["a", "b", "c"], per_item_sleep=0.0)
|
||||
collected = [item async for item in _astream_with_chunk_timeout(source, 5.0)]
|
||||
assert collected == ["a", "b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_with_chunk_timeout_disabled_passes_through() -> None:
|
||||
"""timeout=None / timeout=0 disables the bound; still iterates normally."""
|
||||
source_none = _FakeSource(["a", "b"])
|
||||
collected_none = [
|
||||
item async for item in _astream_with_chunk_timeout(source_none, None)
|
||||
]
|
||||
assert collected_none == ["a", "b"]
|
||||
|
||||
source_zero = _FakeSource(["x", "y"])
|
||||
collected_zero = [
|
||||
item async for item in _astream_with_chunk_timeout(source_zero, 0.0)
|
||||
]
|
||||
assert collected_zero == ["x", "y"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_with_chunk_timeout_fires() -> None:
|
||||
"""Slow source + tight timeout: `StreamChunkTimeoutError` fires."""
|
||||
source = _FakeSource(["a", "b"], per_item_sleep=0.2)
|
||||
with pytest.raises(StreamChunkTimeoutError) as exc_info:
|
||||
async for _ in _astream_with_chunk_timeout(source, 0.05):
|
||||
pass
|
||||
|
||||
# Backward-compat: existing `except TimeoutError:` handlers must still catch.
|
||||
assert isinstance(exc_info.value, asyncio.TimeoutError)
|
||||
assert isinstance(exc_info.value, TimeoutError)
|
||||
|
||||
# Self-describing message names the knob and env var so operators can act.
|
||||
msg = str(exc_info.value)
|
||||
assert "stream_chunk_timeout" in msg
|
||||
assert "LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_with_chunk_timeout_logs_on_fire(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Structured log carries source + timeout_s for aggregate-log filtering."""
|
||||
# Pin the logger + level; don't rely on caplog's default or module
|
||||
# inheritance so the test can't silently no-op.
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
|
||||
source = _FakeSource(["a"], per_item_sleep=0.2)
|
||||
with pytest.raises(StreamChunkTimeoutError):
|
||||
async for _ in _astream_with_chunk_timeout(source, 0.05):
|
||||
pass
|
||||
|
||||
records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.name == "langchain_openai.chat_models._client_utils"
|
||||
and getattr(r, "source", None) == "stream_chunk_timeout"
|
||||
]
|
||||
assert len(records) == 1, f"expected one structured record, got {len(records)}"
|
||||
record = records[0]
|
||||
assert record.levelno == logging.WARNING
|
||||
assert record.__dict__["timeout_s"] == 0.05
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_with_chunk_timeout_closes_source_on_early_exit() -> None:
|
||||
"""aclose() is called on early exit so the httpx connection is released promptly.
|
||||
|
||||
Covers both the timeout-fires path and the consumer-closes-wrapper path.
|
||||
"""
|
||||
# Case 1: timeout fires -> aclose() propagates.
|
||||
timed_out_source = _FakeSource(["a"], per_item_sleep=0.2)
|
||||
with pytest.raises(StreamChunkTimeoutError):
|
||||
async for _ in _astream_with_chunk_timeout(timed_out_source, 0.05):
|
||||
pass
|
||||
assert timed_out_source.aclose_count == 1
|
||||
|
||||
# Case 2: consumer explicitly closes the wrapper after one yield.
|
||||
closer_source = _FakeSource(["a", "b", "c"], per_item_sleep=0.0)
|
||||
# Cast to AsyncGenerator so mypy sees the aclose() method; the helper
|
||||
# is always implemented as an async generator at runtime.
|
||||
wrapper = cast(
|
||||
"AsyncGenerator[Any, None]",
|
||||
_astream_with_chunk_timeout(closer_source, 5.0),
|
||||
)
|
||||
got = await wrapper.__anext__()
|
||||
assert got == "a"
|
||||
await wrapper.aclose()
|
||||
assert closer_source.aclose_count == 1
|
||||
|
||||
|
||||
def test_invalid_stream_chunk_timeout_env_degrades_safely(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Garbage env var -> model init succeeds with the 120s default."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", "not-a-float")
|
||||
model = ChatOpenAI(model=MODEL)
|
||||
assert model.stream_chunk_timeout == 120.0
|
||||
|
||||
|
||||
def test_stream_chunk_timeout_env_kill_switch_zero(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Env-var kill-switch: `_S=0` should disable the wrapper on the model."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", "0")
|
||||
model = ChatOpenAI(model=MODEL)
|
||||
assert model.stream_chunk_timeout == 0.0
|
||||
|
||||
|
||||
def test_stream_chunk_timeout_kwarg_none_disables(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Constructor kwarg opt-out: `stream_chunk_timeout=None` persists."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
model = ChatOpenAI(model=MODEL, stream_chunk_timeout=None)
|
||||
assert model.stream_chunk_timeout is None
|
||||
|
||||
|
||||
def test_stream_chunk_timeout_error_has_structured_attrs() -> None:
|
||||
"""Structured payload mirrors the log `extra=`; no message-regex needed."""
|
||||
err = StreamChunkTimeoutError(0.5, model_name=MODEL, chunks_received=3)
|
||||
assert err.timeout_s == 0.5
|
||||
assert err.model_name == "gpt-5.4"
|
||||
assert err.chunks_received == 3
|
||||
text = str(err)
|
||||
assert "gpt-5.4" in text
|
||||
assert "chunks_received=3" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_with_chunk_timeout_threads_model_name(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""`model_name` flows into both the raised error and the structured log."""
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
source = _FakeSource(["a", "b"], per_item_sleep=0.2)
|
||||
with pytest.raises(StreamChunkTimeoutError) as exc_info:
|
||||
async for _ in _astream_with_chunk_timeout(
|
||||
source, 0.05, model_name="gpt-4o-mini"
|
||||
):
|
||||
pass
|
||||
assert exc_info.value.model_name == "gpt-4o-mini"
|
||||
records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if getattr(r, "source", None) == "stream_chunk_timeout"
|
||||
]
|
||||
assert records
|
||||
assert records[0].__dict__["model_name"] == "gpt-4o-mini"
|
||||
|
||||
|
||||
def test_invalid_stream_chunk_timeout_env_emits_warning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fallback is logged at WARNING so the typo is discoverable."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", "nonsense")
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
ChatOpenAI(model=MODEL)
|
||||
assert any(
|
||||
"LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S" in r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def test_negative_stream_chunk_timeout_env_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Negative timeout typo must not silently disable the wrapper."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setenv("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", "-10")
|
||||
caplog.set_level(
|
||||
logging.WARNING, logger="langchain_openai.chat_models._client_utils"
|
||||
)
|
||||
model = ChatOpenAI(model=MODEL)
|
||||
assert model.stream_chunk_timeout == 120.0
|
||||
assert any(
|
||||
"negative" in r.getMessage().lower()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def test_negative_stream_chunk_timeout_kwarg_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Negative kwarg (e.g., from YAML/JSON configs) must not disable the wrapper.
|
||||
|
||||
Mirrors the env-var path: fall back to the default and emit a WARNING
|
||||
rather than silently treating a negative value as an opt-out — `None` /
|
||||
`0` are the documented off switches.
|
||||
"""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
caplog.set_level(logging.WARNING, logger="langchain_openai.chat_models.base")
|
||||
model = ChatOpenAI(model=MODEL, stream_chunk_timeout=-10)
|
||||
assert model.stream_chunk_timeout == 120.0
|
||||
assert any(
|
||||
"negative" in r.getMessage().lower()
|
||||
and "stream_chunk_timeout" in r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def test_zero_stream_chunk_timeout_kwarg_preserved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`stream_chunk_timeout=0` is the documented opt-out and must persist."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
model = ChatOpenAI(model=MODEL, stream_chunk_timeout=0)
|
||||
assert model.stream_chunk_timeout == 0
|
||||
|
||||
|
||||
class _SlowAsyncContextManager:
|
||||
"""Async context manager that sleeps between streamed items."""
|
||||
|
||||
def __init__(self, chunks: list[Any], per_item_sleep: float) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self._sleep = per_item_sleep
|
||||
self._iter = iter(chunks)
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
await asyncio.sleep(self._sleep)
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration as exc:
|
||||
raise StopAsyncIteration from exc
|
||||
|
||||
|
||||
class _SlowSyncContextManager:
|
||||
"""Sync context manager mirror of `_SlowAsyncContextManager`.
|
||||
|
||||
Sleeps between items in wall-clock time. The sync path never uses
|
||||
`asyncio.wait_for`, so a tight `stream_chunk_timeout` should have no
|
||||
effect here — that is the invariant we want to lock.
|
||||
"""
|
||||
|
||||
def __init__(self, chunks: list[Any], per_item_sleep: float) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self._sleep = per_item_sleep
|
||||
self._iter = iter(chunks)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def __iter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __next__(self) -> Any:
|
||||
import time as _time
|
||||
|
||||
_time.sleep(self._sleep)
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration:
|
||||
raise
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astream_integration_raises_stream_chunk_timeout_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end: slow async stream + tight timeout must raise.
|
||||
|
||||
Guards against a refactor that drops the `_astream_with_chunk_timeout`
|
||||
wrapper from the `_astream` path — unit tests on the helper alone
|
||||
wouldn't catch that regression.
|
||||
"""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
llm = ChatOpenAI(model=MODEL, stream_chunk_timeout=0.05)
|
||||
fake_chunks = [
|
||||
{
|
||||
"id": "c1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
mock_client = AsyncMock()
|
||||
|
||||
async def mock_create(*args: Any, **kwargs: Any) -> _SlowAsyncContextManager:
|
||||
return _SlowAsyncContextManager(fake_chunks, per_item_sleep=0.3)
|
||||
|
||||
mock_client.create = mock_create
|
||||
with (
|
||||
patch.object(llm, "async_client", mock_client),
|
||||
pytest.raises(StreamChunkTimeoutError) as exc_info,
|
||||
):
|
||||
async for _ in llm.astream("hello"):
|
||||
pass
|
||||
assert exc_info.value.model_name == MODEL
|
||||
|
||||
|
||||
def test_stream_sync_not_wrapped_by_chunk_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Sync `llm.stream()` must not be subject to `stream_chunk_timeout`.
|
||||
|
||||
Setting `stream_chunk_timeout=0.01` with a 100ms-per-chunk sync source
|
||||
would raise if the wrapper were (incorrectly) applied to the sync path.
|
||||
Completion without error proves the contract.
|
||||
"""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
llm = ChatOpenAI(model=MODEL, stream_chunk_timeout=0.01)
|
||||
fake_chunks = [
|
||||
{
|
||||
"id": "c1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o",
|
||||
"choices": [
|
||||
{"index": 0, "delta": {}, "finish_reason": "stop"},
|
||||
],
|
||||
},
|
||||
]
|
||||
mock_client = MagicMock()
|
||||
|
||||
def _create(*_args: Any, **_kwargs: Any) -> _SlowSyncContextManager:
|
||||
return _SlowSyncContextManager(fake_chunks, per_item_sleep=0.1)
|
||||
|
||||
mock_client.create = _create
|
||||
with patch.object(llm, "client", mock_client):
|
||||
chunks = list(llm.stream("hello"))
|
||||
assert chunks, "sync stream should have delivered chunks"
|
||||
@@ -7,6 +7,7 @@ EXPECTED_ALL = [
|
||||
"AzureOpenAI",
|
||||
"AzureChatOpenAI",
|
||||
"AzureOpenAIEmbeddings",
|
||||
"StreamChunkTimeoutError",
|
||||
"custom_tool",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user