Skip to content

Commit f05b7df

Browse files
committed
fix(transport): Enable TCP keepalive for long partition requests
Refresh idle NAT and load-balancer flows while partition requests wait for response headers. Enable SO_KEEPALIVE with 60s idle, 30s interval and five probes on SDK-owned sync, async and split-PDF transports. Resolve supported platform options defensively and expose validated environment overrides. Caller-provided clients retain their existing transport configuration. HTTPX 0.28 disables environment proxy discovery when transport is supplied. Rebuild its environment proxy mounts with get_environment_proxies, including NO_PROXY exclusions, and apply socket options to proxy transports as well. Move split-PDF limits, verify and cert into every transport so these settings remain effective. Log resolved probe settings alongside pool and TLS settings. Keep the changelog entry under 0.46.3: no corresponding remote tag or GitHub release exists. Generation was decommissioned in cb867f0; .genignore is absent from the current checkout, and the SDK entry point is maintained by hand. Validation: make lint (pylint 10.00/10, mypy clean); make test-unit (353 passed, 1 xfailed). A Docker MASQUERADE router with a 20s conntrack timeout and a 45s server delay returned 200 at 45.0s with keepalive idle/interval 5s; disabling keepalive produced ReadTimeout at 90.0s.
1 parent ba25d25 commit f05b7df

7 files changed

Lines changed: 326 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +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.
45
* 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.
56

67
## 0.46.2

‎_test_unstructured_client/unit/test_split_collection_urls.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def respond(request):
6262
monkeypatch.setattr(
6363
httpx,
6464
"AsyncClient",
65-
lambda **kwargs: original_async_client(transport=transport, **kwargs),
65+
lambda **kwargs: original_async_client(**dict(kwargs, transport=transport, mounts={})),
6666
)
6767
return sdk, sent, sync_client, async_client
6868

‎_test_unstructured_client/unit/test_split_pdf_hook.py‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -498,15 +498,15 @@ async def test_unit_run_tasks_pool_limits_configurable_via_env(
498498
monkeypatch.setenv("UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY", "30.0")
499499

500500
captured: dict[str, httpx.Limits] = {}
501-
real_async_client = httpx.AsyncClient
501+
real_async_transport = httpx.AsyncHTTPTransport
502502

503-
def _capturing_async_client(*args, **kwargs):
503+
def _capturing_async_transport(*args, **kwargs):
504504
captured["limits"] = kwargs.get("limits")
505-
return real_async_client(*args, **kwargs)
505+
return real_async_transport(*args, **kwargs)
506506

507507
with patch(
508-
"unstructured_client._hooks.custom.split_pdf_hook.httpx.AsyncClient",
509-
side_effect=_capturing_async_client,
508+
"unstructured_client._hooks.custom.http_transport.httpx.AsyncHTTPTransport",
509+
side_effect=_capturing_async_transport,
510510
):
511511
await run_tasks(
512512
[partial(_request_mock, fails=False, content="ok")],
@@ -622,24 +622,22 @@ def test_unit_resolve_tls_config_client_cert_and_key_split(
622622

623623

624624
@pytest.mark.asyncio
625-
async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_client(
625+
async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_transport(
626626
monkeypatch: pytest.MonkeyPatch, tmp_path
627627
):
628-
"""run_tasks() actually wires verify+cert into httpx.AsyncClient(). End-to-
629-
end check that _resolve_tls_config is called and its result reaches the
630-
client construction. AsyncClient is fully mocked so we don't have to feed
631-
httpx a real cert chain."""
628+
"""Split requests apply TLS and pool settings to the transport."""
632629
ca_bundle = tmp_path / "ca.pem"
633630
ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
634631
monkeypatch.setenv("SSL_CERT_FILE", str(ca_bundle))
635632
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.pem")
636633

634+
monkeypatch.setenv("NO_PROXY", "*")
635+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_MAX_CONNECTIONS", "17")
637636
captured: dict = {}
638637

639638
class _MockAsyncClient:
640639
def __init__(self, *args, **kwargs):
641-
captured["verify"] = kwargs.get("verify")
642-
captured["cert"] = kwargs.get("cert")
640+
captured["transport"] = kwargs.get("transport")
643641

644642
async def __aenter__(self):
645643
return self
@@ -648,16 +646,22 @@ async def __aexit__(self, *exc):
648646
return False
649647

650648
with patch(
651-
"unstructured_client._hooks.custom.split_pdf_hook.httpx.AsyncClient",
649+
"unstructured_client._hooks.custom.http_transport.httpx.AsyncClient",
652650
new=_MockAsyncClient,
653-
):
651+
), patch(
652+
"unstructured_client._hooks.custom.http_transport.httpx.AsyncHTTPTransport"
653+
) as transport:
654654
await run_tasks(
655655
[partial(_request_mock, fails=False, content="ok")],
656656
allow_failed=True,
657657
)
658658

659-
assert captured["verify"] == str(ca_bundle)
660-
assert captured["cert"] == "/etc/ssl/client.pem"
659+
kwargs = transport.call_args.kwargs
660+
assert captured["transport"] is transport.return_value
661+
assert kwargs["verify"] == str(ca_bundle)
662+
assert kwargs["cert"] == "/etc/ssl/client.pem"
663+
assert kwargs["limits"].max_connections == 17
664+
assert kwargs["socket_options"]
661665

662666

663667
@patch("unstructured_client._hooks.custom.form_utils.Path")
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""TCP keepalive configuration and transport integration."""
2+
3+
import asyncio
4+
import socket
5+
import sys
6+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7+
from threading import Thread
8+
9+
import httpcore
10+
import httpx
11+
import pytest
12+
13+
from unstructured_client import UnstructuredClient
14+
from unstructured_client._hooks.custom import http_transport as transport
15+
16+
17+
@pytest.fixture(autouse=True)
18+
def clean_env(monkeypatch):
19+
for suffix in ("", "_IDLE", "_INTERVAL", "_COUNT"):
20+
monkeypatch.delenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + suffix, raising=False)
21+
monkeypatch.setenv("NO_PROXY", "*")
22+
23+
24+
@pytest.mark.parametrize("platform", ["linux", "macos", "macos_old", "windows"])
25+
def test_unit_platform_options(monkeypatch, platform):
26+
for name in ("TCP_KEEPIDLE", "TCP_KEEPALIVE", "TCP_KEEPINTVL", "TCP_KEEPCNT"):
27+
monkeypatch.delattr(socket, name, raising=False)
28+
expected = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
29+
if platform != "windows":
30+
monkeypatch.setattr(socket, "TCP_KEEPIDLE" if platform == "linux" else "TCP_KEEPALIVE", 100, raising=False)
31+
expected.append((socket.IPPROTO_TCP, 100, 60))
32+
if platform != "macos_old":
33+
monkeypatch.setattr(socket, "TCP_KEEPINTVL", 101, raising=False)
34+
monkeypatch.setattr(socket, "TCP_KEEPCNT", 102, raising=False)
35+
expected += [(socket.IPPROTO_TCP, 101, 30), (socket.IPPROTO_TCP, 102, 5)]
36+
assert transport.resolve_tcp_keepalive().socket_options() == expected
37+
38+
39+
@pytest.mark.parametrize("value", ["0", "false", "False", " FALSE "])
40+
def test_unit_disabled(monkeypatch, value):
41+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE", value)
42+
assert transport.resolve_tcp_keepalive().socket_options() == []
43+
44+
45+
@pytest.mark.parametrize("suffix,default", [("IDLE", 60), ("INTERVAL", 30), ("COUNT", 5)])
46+
@pytest.mark.parametrize("value", ["0", "-1", "bad", "1.5", ""])
47+
def test_unit_invalid_value(monkeypatch, caplog, suffix, default, value):
48+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value)
49+
assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == default
50+
assert "using default" in caplog.text
51+
52+
53+
def test_unit_custom_values(monkeypatch):
54+
for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")):
55+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value)
56+
assert transport.resolve_tcp_keepalive() == transport.TCPKeepAlive(True, 5, 6, 7)
57+
58+
59+
def test_unit_sdk_owned_clients():
60+
sdk = UnstructuredClient()
61+
options = transport.resolve_tcp_keepalive().socket_options()
62+
assert sdk.sdk_configuration.client._transport.base_transport._pool._socket_options == options
63+
assert sdk.sdk_configuration.async_client._transport._pool._socket_options == options
64+
sdk.sdk_configuration.client.close()
65+
asyncio.run(sdk.sdk_configuration.async_client.aclose())
66+
67+
68+
def test_unit_supplied_clients_untouched():
69+
sync = httpx.Client()
70+
asynchronous = httpx.AsyncClient()
71+
sync_transport = sync._transport
72+
async_transport = asynchronous._transport
73+
sdk = UnstructuredClient(client=sync, async_client=asynchronous)
74+
assert sdk.sdk_configuration.client is sync
75+
assert sdk.sdk_configuration.async_client is asynchronous
76+
assert sync._transport.base_transport is sync_transport
77+
assert asynchronous._transport is async_transport
78+
assert sync_transport._pool._socket_options is None
79+
assert asynchronous._transport._pool._socket_options is None
80+
sync.close()
81+
asyncio.run(asynchronous.aclose())
82+
83+
84+
@pytest.mark.parametrize("asynchronous", [False, True])
85+
def test_unit_environment_proxy_routes(monkeypatch, asynchronous):
86+
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:8123")
87+
monkeypatch.setenv("NO_PROXY", "excluded.example")
88+
client = transport.create_async_http_client() if asynchronous else transport.create_http_client()
89+
proxied = client._transport_for_url(httpx.URL("https://api.example"))
90+
assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy)
91+
assert proxied._pool._proxy_url.host == b"127.0.0.1"
92+
assert proxied._pool._socket_options == transport.resolve_tcp_keepalive().socket_options()
93+
assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport
94+
if asynchronous:
95+
asyncio.run(client.aclose())
96+
else:
97+
client.close()
98+
99+
100+
@pytest.mark.parametrize("asynchronous", [False, True])
101+
def test_unit_real_socket_keepalive(asynchronous):
102+
if not hasattr(socket, "SO_KEEPALIVE"):
103+
pytest.skip("SO_KEEPALIVE unavailable")
104+
105+
class Handler(BaseHTTPRequestHandler):
106+
protocol_version = "HTTP/1.1"
107+
108+
def do_GET(self):
109+
self.send_response(200)
110+
self.send_header("Content-Length", "2")
111+
self.end_headers()
112+
self.wfile.write(b"ok")
113+
114+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
115+
thread = Thread(target=server.serve_forever, daemon=True)
116+
thread.start()
117+
url = f"http://127.0.0.1:{server.server_port}"
118+
119+
def check(response):
120+
stream = response.extensions["network_stream"]
121+
sock = stream.get_extra_info("socket")
122+
enabled = sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE)
123+
# Darwin reports the option bit mask rather than a normalized boolean.
124+
if sys.platform == "darwin":
125+
assert enabled != 0
126+
else:
127+
assert enabled == 1
128+
129+
async def request():
130+
async with transport.create_async_http_client() as client:
131+
async with client.stream("GET", url) as response:
132+
check(response)
133+
await response.aread()
134+
135+
try:
136+
if asynchronous:
137+
asyncio.run(request())
138+
else:
139+
with transport.create_http_client() as client:
140+
with client.stream("GET", url) as response:
141+
check(response)
142+
response.read()
143+
finally:
144+
server.shutdown()
145+
server.server_close()
146+
thread.join()
147+
148+
149+
@pytest.mark.parametrize("asynchronous", [False, True])
150+
def test_unit_https_proxy_receives_connect(monkeypatch, asynchronous):
151+
destinations = []
152+
153+
class ProxyHandler(BaseHTTPRequestHandler):
154+
def do_CONNECT(self):
155+
destinations.append(self.path)
156+
self.send_error(502)
157+
158+
server = ThreadingHTTPServer(("127.0.0.1", 0), ProxyHandler)
159+
thread = Thread(target=server.serve_forever, daemon=True)
160+
thread.start()
161+
monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{server.server_port}")
162+
monkeypatch.setenv("NO_PROXY", "")
163+
164+
async def request():
165+
async with transport.create_async_http_client() as client:
166+
await client.get("https://proxy-target.invalid/")
167+
168+
try:
169+
with pytest.raises(httpx.ProxyError, match="502"):
170+
if asynchronous:
171+
asyncio.run(request())
172+
else:
173+
with transport.create_http_client() as client:
174+
client.get("https://proxy-target.invalid/")
175+
assert destinations == ["proxy-target.invalid:443"]
176+
finally:
177+
server.shutdown()
178+
server.server_close()
179+
thread.join()
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""HTTP clients with TCP keepalive and HTTPX environment proxy routing."""
2+
3+
import logging
4+
import os
5+
import socket
6+
from dataclasses import dataclass
7+
from typing import Any
8+
9+
import httpx
10+
from httpx._utils import get_environment_proxies
11+
12+
from .common import UNSTRUCTURED_CLIENT_LOGGER_NAME
13+
14+
logger = logging.getLogger(UNSTRUCTURED_CLIENT_LOGGER_NAME)
15+
16+
17+
def _positive_int(name: str, default: int) -> int:
18+
value = os.getenv(name, str(default))
19+
try:
20+
parsed = int(value)
21+
if parsed > 0:
22+
return parsed
23+
except ValueError:
24+
pass
25+
logger.warning("Invalid %s=%r; using default %d", name, value, default)
26+
return default
27+
28+
29+
@dataclass(frozen=True)
30+
class TCPKeepAlive:
31+
"""Resolved TCP probe settings, in seconds and number of probes."""
32+
33+
enabled: bool
34+
idle: int
35+
interval: int
36+
count: int
37+
38+
def socket_options(self) -> list[tuple[int, int, int]]:
39+
"""Use only the TCP options available on the current platform."""
40+
if not self.enabled:
41+
return []
42+
options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
43+
idle_option = getattr(socket, "TCP_KEEPIDLE", None)
44+
if idle_option is None:
45+
idle_option = getattr(socket, "TCP_KEEPALIVE", None)
46+
for option, value in (
47+
(idle_option, self.idle),
48+
(getattr(socket, "TCP_KEEPINTVL", None), self.interval),
49+
(getattr(socket, "TCP_KEEPCNT", None), self.count),
50+
):
51+
if option is not None:
52+
options.append((socket.IPPROTO_TCP, option, value))
53+
return options
54+
55+
56+
def resolve_tcp_keepalive() -> TCPKeepAlive:
57+
"""Read keepalive settings, falling back on invalid positive integers."""
58+
prefix = "UNSTRUCTURED_CLIENT_TCP_KEEPALIVE"
59+
return TCPKeepAlive(
60+
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),
64+
)
65+
66+
67+
def _client_transport_options(
68+
transport_type: Any,
69+
keepalive: TCPKeepAlive | None,
70+
transport_options: dict[str, Any],
71+
) -> dict[str, Any]:
72+
settings = keepalive if keepalive is not None else resolve_tcp_keepalive()
73+
options = dict(transport_options, socket_options=settings.socket_options())
74+
# HTTPX disables automatic environment proxy discovery with a custom transport.
75+
# Reuse its URL patterns, including None mounts for NO_PROXY exclusions.
76+
mounts = {
77+
pattern: transport_type(proxy=proxy, **options) if proxy else None
78+
for pattern, proxy in get_environment_proxies().items()
79+
}
80+
return {"transport": transport_type(**options), "mounts": mounts}
81+
82+
83+
def create_http_client() -> httpx.Client:
84+
"""Construct the SDK's synchronous client with HTTPX defaults."""
85+
return httpx.Client(**_client_transport_options(httpx.HTTPTransport, None, {}))
86+
87+
88+
def create_async_http_client(
89+
*,
90+
keepalive: TCPKeepAlive | None = None,
91+
transport_options: dict[str, Any] | None = None,
92+
**client_options: Any,
93+
) -> httpx.AsyncClient:
94+
"""Construct an async client, applying TLS and pool settings to every route."""
95+
return httpx.AsyncClient(
96+
**client_options,
97+
**_client_transport_options(
98+
httpx.AsyncHTTPTransport, keepalive, transport_options or {}
99+
),
100+
)

0 commit comments

Comments
 (0)