Skip to content

Commit eaf4a03

Browse files
committed
fix(transport): Bound keepalive settings and guard HTTPX internals
1 parent f05b7df commit eaf4a03

3 files changed

Lines changed: 69 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
## 0.46.3
22

33
### Fixes
4-
* Long partitions behind a NAT or load balancer idle timeout could never return, leaving the client waiting for its full read timeout. SDK-owned HTTP clients now enable TCP keepalive, including split-PDF and environment-proxy connections. Configure probes with `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_IDLE` (60 seconds), `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_INTERVAL` (30 seconds), and `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_COUNT` (5); set `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE=0` or `false` to disable. Caller-supplied clients are unchanged.
4+
* Long partitions behind a NAT or load balancer idle timeout could never return, leaving the client waiting for its full read timeout. SDK-owned HTTP clients now enable TCP keepalive, including split-PDF requests. Connections made through an HTTP(S)/SOCKS proxy are not covered. Configure probes with `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_IDLE` (60 seconds), `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_INTERVAL` (30 seconds), and `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_COUNT` (5); set `UNSTRUCTURED_CLIENT_TCP_KEEPALIVE=0` or `false` to disable. Caller-supplied clients are unchanged.
55
* Keep the deployment path in split-PDF requests. With a `server_url` that includes a path, such as `https://host/deployment`, splitting a PDF sent its collection request to `https://host/general/docs` instead of `https://host/deployment/general/docs`, which returned 404 before any chunks were processed. The collection URL is now derived from the partition request by replacing only its trailing `/general/v0/general` route, for sync and async calls and for a `server_url` passed to the operation.
66

77
## 0.46.2

‎_test_unstructured_client/unit/test_tcp_keepalive.py‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
77
from threading import Thread
8+
from unittest.mock import patch
89

910
import httpcore
1011
import httpx
@@ -16,6 +17,8 @@
1617

1718
@pytest.fixture(autouse=True)
1819
def clean_env(monkeypatch):
20+
if hasattr(transport, "_log_keepalive_unavailable"):
21+
transport._log_keepalive_unavailable.cache_clear()
1922
for suffix in ("", "_IDLE", "_INTERVAL", "_COUNT"):
2023
monkeypatch.delenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + suffix, raising=False)
2124
monkeypatch.setenv("NO_PROXY", "*")
@@ -50,6 +53,43 @@ def test_unit_invalid_value(monkeypatch, caplog, suffix, default, value):
5053
assert "using default" in caplog.text
5154

5255

56+
@pytest.mark.parametrize("suffix,maximum,default", [
57+
("IDLE", 32767, 60), ("INTERVAL", 32767, 30), ("COUNT", 127, 5),
58+
])
59+
@pytest.mark.parametrize("above_maximum", [False, True])
60+
def test_unit_value_bounds(monkeypatch, caplog, suffix, maximum, default, above_maximum):
61+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix,
62+
str(maximum + int(above_maximum)))
63+
expected = default if above_maximum else maximum
64+
assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == expected
65+
assert ("using default" in caplog.text) == above_maximum
66+
67+
68+
@pytest.mark.parametrize("asynchronous", [False, True])
69+
def test_unit_missing_proxy_helper_client_options(monkeypatch, asynchronous):
70+
monkeypatch.setattr(transport, "get_environment_proxies", None)
71+
if asynchronous:
72+
options = {"verify": False, "cert": None, "limits": httpx.Limits(max_connections=3)}
73+
with patch.object(httpx, "AsyncClient") as constructor:
74+
transport.create_async_http_client(timeout=42, transport_options=options)
75+
constructor.assert_called_once_with(timeout=42, **options)
76+
else:
77+
with patch.object(httpx, "Client") as constructor:
78+
transport.create_http_client()
79+
constructor.assert_called_once_with()
80+
81+
82+
def test_unit_missing_proxy_helper_logs_once(monkeypatch, caplog):
83+
monkeypatch.setattr(transport, "get_environment_proxies", None)
84+
caplog.set_level("DEBUG", logger=transport.logger.name)
85+
with transport.create_http_client():
86+
pass
87+
asyncio.run(transport.create_async_http_client().aclose())
88+
messages = [record for record in caplog.records if "TCP keepalive is unavailable" in record.message]
89+
assert len(messages) == 1
90+
assert messages[0].levelname == "DEBUG"
91+
92+
5393
def test_unit_custom_values(monkeypatch):
5494
for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")):
5595
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value)
@@ -81,15 +121,20 @@ def test_unit_supplied_clients_untouched():
81121
asyncio.run(asynchronous.aclose())
82122

83123

124+
@pytest.mark.parametrize("missing_proxy_helper", [False, True])
84125
@pytest.mark.parametrize("asynchronous", [False, True])
85-
def test_unit_environment_proxy_routes(monkeypatch, asynchronous):
126+
def test_unit_environment_proxy_routes(monkeypatch, asynchronous, missing_proxy_helper):
127+
if missing_proxy_helper:
128+
monkeypatch.setattr(transport, "get_environment_proxies", None)
86129
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:8123")
87130
monkeypatch.setenv("NO_PROXY", "excluded.example")
88131
client = transport.create_async_http_client() if asynchronous else transport.create_http_client()
89132
proxied = client._transport_for_url(httpx.URL("https://api.example"))
90133
assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy)
91134
assert proxied._pool._proxy_url.host == b"127.0.0.1"
92-
assert proxied._pool._socket_options == transport.resolve_tcp_keepalive().socket_options()
135+
if missing_proxy_helper:
136+
assert client._transport._pool._socket_options is None
137+
assert proxied._pool._socket_options is None
93138
assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport
94139
if asynchronous:
95140
asyncio.run(client.aclose())

‎src/unstructured_client/_hooks/custom/http_transport.py‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1-
"""HTTP clients with TCP keepalive and HTTPX environment proxy routing."""
1+
"""HTTP clients with TCP keepalive for direct connections and environment proxy routing."""
22

33
import logging
44
import os
55
import socket
66
from dataclasses import dataclass
7+
from functools import lru_cache
78
from typing import Any
89

910
import httpx
10-
from httpx._utils import get_environment_proxies
11+
try:
12+
from httpx._utils import get_environment_proxies
13+
except ImportError:
14+
get_environment_proxies = None # type: ignore[assignment]
1115

1216
from .common import UNSTRUCTURED_CLIENT_LOGGER_NAME
1317

1418
logger = logging.getLogger(UNSTRUCTURED_CLIENT_LOGGER_NAME)
1519

1620

17-
def _positive_int(name: str, default: int) -> int:
21+
def _positive_int(name: str, default: int, maximum: int) -> int:
1822
value = os.getenv(name, str(default))
1923
try:
2024
parsed = int(value)
21-
if parsed > 0:
25+
if 1 <= parsed <= maximum:
2226
return parsed
2327
except ValueError:
2428
pass
@@ -54,25 +58,34 @@ def socket_options(self) -> list[tuple[int, int, int]]:
5458

5559

5660
def resolve_tcp_keepalive() -> TCPKeepAlive:
57-
"""Read keepalive settings, falling back on invalid positive integers."""
61+
"""Read keepalive settings, falling back on values outside the supported bounds."""
5862
prefix = "UNSTRUCTURED_CLIENT_TCP_KEEPALIVE"
5963
return TCPKeepAlive(
6064
enabled=os.getenv(prefix, "1").strip().lower() not in ("0", "false"),
61-
idle=_positive_int(prefix + "_IDLE", 60),
62-
interval=_positive_int(prefix + "_INTERVAL", 30),
63-
count=_positive_int(prefix + "_COUNT", 5),
65+
idle=_positive_int(prefix + "_IDLE", 60, 32767),
66+
interval=_positive_int(prefix + "_INTERVAL", 30, 32767),
67+
count=_positive_int(prefix + "_COUNT", 5, 127),
6468
)
6569

6670

71+
@lru_cache(maxsize=1)
72+
def _log_keepalive_unavailable() -> None:
73+
logger.debug("TCP keepalive is unavailable with this httpx version")
74+
75+
6776
def _client_transport_options(
6877
transport_type: Any,
6978
keepalive: TCPKeepAlive | None,
7079
transport_options: dict[str, Any],
7180
) -> dict[str, Any]:
81+
if get_environment_proxies is None:
82+
_log_keepalive_unavailable()
83+
return transport_options
7284
settings = keepalive if keepalive is not None else resolve_tcp_keepalive()
7385
options = dict(transport_options, socket_options=settings.socket_options())
7486
# HTTPX disables automatic environment proxy discovery with a custom transport.
7587
# Reuse its URL patterns, including None mounts for NO_PROXY exclusions.
88+
# HTTP and SOCKS proxy connections do not apply these socket options.
7689
mounts = {
7790
pattern: transport_type(proxy=proxy, **options) if proxy else None
7891
for pattern, proxy in get_environment_proxies().items()

0 commit comments

Comments
 (0)