From f05b7df47fcde487f9543213c084a48b26b9062a Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:35:17 -0400 Subject: [PATCH 1/4] 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. --- CHANGELOG.md | 1 + .../unit/test_split_collection_urls.py | 2 +- .../unit/test_split_pdf_hook.py | 36 ++-- .../unit/test_tcp_keepalive.py | 179 ++++++++++++++++++ .../_hooks/custom/http_transport.py | 100 ++++++++++ .../_hooks/custom/split_pdf_hook.py | 24 ++- src/unstructured_client/sdk.py | 10 +- 7 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 _test_unstructured_client/unit/test_tcp_keepalive.py create mode 100644 src/unstructured_client/_hooks/custom/http_transport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c6a13f..ccf175a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.46.3 ### Fixes +* 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. * 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. ## 0.46.2 diff --git a/_test_unstructured_client/unit/test_split_collection_urls.py b/_test_unstructured_client/unit/test_split_collection_urls.py index 99ab3fe9..3320e28d 100644 --- a/_test_unstructured_client/unit/test_split_collection_urls.py +++ b/_test_unstructured_client/unit/test_split_collection_urls.py @@ -62,7 +62,7 @@ def respond(request): monkeypatch.setattr( httpx, "AsyncClient", - lambda **kwargs: original_async_client(transport=transport, **kwargs), + lambda **kwargs: original_async_client(**dict(kwargs, transport=transport, mounts={})), ) return sdk, sent, sync_client, async_client diff --git a/_test_unstructured_client/unit/test_split_pdf_hook.py b/_test_unstructured_client/unit/test_split_pdf_hook.py index be9ca472..a1a541ee 100644 --- a/_test_unstructured_client/unit/test_split_pdf_hook.py +++ b/_test_unstructured_client/unit/test_split_pdf_hook.py @@ -498,15 +498,15 @@ async def test_unit_run_tasks_pool_limits_configurable_via_env( monkeypatch.setenv("UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY", "30.0") captured: dict[str, httpx.Limits] = {} - real_async_client = httpx.AsyncClient + real_async_transport = httpx.AsyncHTTPTransport - def _capturing_async_client(*args, **kwargs): + def _capturing_async_transport(*args, **kwargs): captured["limits"] = kwargs.get("limits") - return real_async_client(*args, **kwargs) + return real_async_transport(*args, **kwargs) with patch( - "unstructured_client._hooks.custom.split_pdf_hook.httpx.AsyncClient", - side_effect=_capturing_async_client, + "unstructured_client._hooks.custom.http_transport.httpx.AsyncHTTPTransport", + side_effect=_capturing_async_transport, ): await run_tasks( [partial(_request_mock, fails=False, content="ok")], @@ -622,24 +622,22 @@ def test_unit_resolve_tls_config_client_cert_and_key_split( @pytest.mark.asyncio -async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_client( +async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_transport( monkeypatch: pytest.MonkeyPatch, tmp_path ): - """run_tasks() actually wires verify+cert into httpx.AsyncClient(). End-to- - end check that _resolve_tls_config is called and its result reaches the - client construction. AsyncClient is fully mocked so we don't have to feed - httpx a real cert chain.""" + """Split requests apply TLS and pool settings to the transport.""" ca_bundle = tmp_path / "ca.pem" ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") monkeypatch.setenv("SSL_CERT_FILE", str(ca_bundle)) monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.pem") + monkeypatch.setenv("NO_PROXY", "*") + monkeypatch.setenv("UNSTRUCTURED_CLIENT_MAX_CONNECTIONS", "17") captured: dict = {} class _MockAsyncClient: def __init__(self, *args, **kwargs): - captured["verify"] = kwargs.get("verify") - captured["cert"] = kwargs.get("cert") + captured["transport"] = kwargs.get("transport") async def __aenter__(self): return self @@ -648,16 +646,22 @@ async def __aexit__(self, *exc): return False with patch( - "unstructured_client._hooks.custom.split_pdf_hook.httpx.AsyncClient", + "unstructured_client._hooks.custom.http_transport.httpx.AsyncClient", new=_MockAsyncClient, - ): + ), patch( + "unstructured_client._hooks.custom.http_transport.httpx.AsyncHTTPTransport" + ) as transport: await run_tasks( [partial(_request_mock, fails=False, content="ok")], allow_failed=True, ) - assert captured["verify"] == str(ca_bundle) - assert captured["cert"] == "/etc/ssl/client.pem" + kwargs = transport.call_args.kwargs + assert captured["transport"] is transport.return_value + assert kwargs["verify"] == str(ca_bundle) + assert kwargs["cert"] == "/etc/ssl/client.pem" + assert kwargs["limits"].max_connections == 17 + assert kwargs["socket_options"] @patch("unstructured_client._hooks.custom.form_utils.Path") diff --git a/_test_unstructured_client/unit/test_tcp_keepalive.py b/_test_unstructured_client/unit/test_tcp_keepalive.py new file mode 100644 index 00000000..958a300c --- /dev/null +++ b/_test_unstructured_client/unit/test_tcp_keepalive.py @@ -0,0 +1,179 @@ +"""TCP keepalive configuration and transport integration.""" + +import asyncio +import socket +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + +import httpcore +import httpx +import pytest + +from unstructured_client import UnstructuredClient +from unstructured_client._hooks.custom import http_transport as transport + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + for suffix in ("", "_IDLE", "_INTERVAL", "_COUNT"): + monkeypatch.delenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + suffix, raising=False) + monkeypatch.setenv("NO_PROXY", "*") + + +@pytest.mark.parametrize("platform", ["linux", "macos", "macos_old", "windows"]) +def test_unit_platform_options(monkeypatch, platform): + for name in ("TCP_KEEPIDLE", "TCP_KEEPALIVE", "TCP_KEEPINTVL", "TCP_KEEPCNT"): + monkeypatch.delattr(socket, name, raising=False) + expected = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] + if platform != "windows": + monkeypatch.setattr(socket, "TCP_KEEPIDLE" if platform == "linux" else "TCP_KEEPALIVE", 100, raising=False) + expected.append((socket.IPPROTO_TCP, 100, 60)) + if platform != "macos_old": + monkeypatch.setattr(socket, "TCP_KEEPINTVL", 101, raising=False) + monkeypatch.setattr(socket, "TCP_KEEPCNT", 102, raising=False) + expected += [(socket.IPPROTO_TCP, 101, 30), (socket.IPPROTO_TCP, 102, 5)] + assert transport.resolve_tcp_keepalive().socket_options() == expected + + +@pytest.mark.parametrize("value", ["0", "false", "False", " FALSE "]) +def test_unit_disabled(monkeypatch, value): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE", value) + assert transport.resolve_tcp_keepalive().socket_options() == [] + + +@pytest.mark.parametrize("suffix,default", [("IDLE", 60), ("INTERVAL", 30), ("COUNT", 5)]) +@pytest.mark.parametrize("value", ["0", "-1", "bad", "1.5", ""]) +def test_unit_invalid_value(monkeypatch, caplog, suffix, default, value): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) + assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == default + assert "using default" in caplog.text + + +def test_unit_custom_values(monkeypatch): + for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) + assert transport.resolve_tcp_keepalive() == transport.TCPKeepAlive(True, 5, 6, 7) + + +def test_unit_sdk_owned_clients(): + sdk = UnstructuredClient() + options = transport.resolve_tcp_keepalive().socket_options() + assert sdk.sdk_configuration.client._transport.base_transport._pool._socket_options == options + assert sdk.sdk_configuration.async_client._transport._pool._socket_options == options + sdk.sdk_configuration.client.close() + asyncio.run(sdk.sdk_configuration.async_client.aclose()) + + +def test_unit_supplied_clients_untouched(): + sync = httpx.Client() + asynchronous = httpx.AsyncClient() + sync_transport = sync._transport + async_transport = asynchronous._transport + sdk = UnstructuredClient(client=sync, async_client=asynchronous) + assert sdk.sdk_configuration.client is sync + assert sdk.sdk_configuration.async_client is asynchronous + assert sync._transport.base_transport is sync_transport + assert asynchronous._transport is async_transport + assert sync_transport._pool._socket_options is None + assert asynchronous._transport._pool._socket_options is None + sync.close() + asyncio.run(asynchronous.aclose()) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_unit_environment_proxy_routes(monkeypatch, asynchronous): + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:8123") + monkeypatch.setenv("NO_PROXY", "excluded.example") + client = transport.create_async_http_client() if asynchronous else transport.create_http_client() + proxied = client._transport_for_url(httpx.URL("https://api.example")) + assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy) + assert proxied._pool._proxy_url.host == b"127.0.0.1" + assert proxied._pool._socket_options == transport.resolve_tcp_keepalive().socket_options() + assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport + if asynchronous: + asyncio.run(client.aclose()) + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_unit_real_socket_keepalive(asynchronous): + if not hasattr(socket, "SO_KEEPALIVE"): + pytest.skip("SO_KEEPALIVE unavailable") + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}" + + def check(response): + stream = response.extensions["network_stream"] + sock = stream.get_extra_info("socket") + enabled = sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) + # Darwin reports the option bit mask rather than a normalized boolean. + if sys.platform == "darwin": + assert enabled != 0 + else: + assert enabled == 1 + + async def request(): + async with transport.create_async_http_client() as client: + async with client.stream("GET", url) as response: + check(response) + await response.aread() + + try: + if asynchronous: + asyncio.run(request()) + else: + with transport.create_http_client() as client: + with client.stream("GET", url) as response: + check(response) + response.read() + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_unit_https_proxy_receives_connect(monkeypatch, asynchronous): + destinations = [] + + class ProxyHandler(BaseHTTPRequestHandler): + def do_CONNECT(self): + destinations.append(self.path) + self.send_error(502) + + server = ThreadingHTTPServer(("127.0.0.1", 0), ProxyHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{server.server_port}") + monkeypatch.setenv("NO_PROXY", "") + + async def request(): + async with transport.create_async_http_client() as client: + await client.get("https://proxy-target.invalid/") + + try: + with pytest.raises(httpx.ProxyError, match="502"): + if asynchronous: + asyncio.run(request()) + else: + with transport.create_http_client() as client: + client.get("https://proxy-target.invalid/") + assert destinations == ["proxy-target.invalid:443"] + finally: + server.shutdown() + server.server_close() + thread.join() diff --git a/src/unstructured_client/_hooks/custom/http_transport.py b/src/unstructured_client/_hooks/custom/http_transport.py new file mode 100644 index 00000000..97375abf --- /dev/null +++ b/src/unstructured_client/_hooks/custom/http_transport.py @@ -0,0 +1,100 @@ +"""HTTP clients with TCP keepalive and HTTPX environment proxy routing.""" + +import logging +import os +import socket +from dataclasses import dataclass +from typing import Any + +import httpx +from httpx._utils import get_environment_proxies + +from .common import UNSTRUCTURED_CLIENT_LOGGER_NAME + +logger = logging.getLogger(UNSTRUCTURED_CLIENT_LOGGER_NAME) + + +def _positive_int(name: str, default: int) -> int: + value = os.getenv(name, str(default)) + try: + parsed = int(value) + if parsed > 0: + return parsed + except ValueError: + pass + logger.warning("Invalid %s=%r; using default %d", name, value, default) + return default + + +@dataclass(frozen=True) +class TCPKeepAlive: + """Resolved TCP probe settings, in seconds and number of probes.""" + + enabled: bool + idle: int + interval: int + count: int + + def socket_options(self) -> list[tuple[int, int, int]]: + """Use only the TCP options available on the current platform.""" + if not self.enabled: + return [] + options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] + idle_option = getattr(socket, "TCP_KEEPIDLE", None) + if idle_option is None: + idle_option = getattr(socket, "TCP_KEEPALIVE", None) + for option, value in ( + (idle_option, self.idle), + (getattr(socket, "TCP_KEEPINTVL", None), self.interval), + (getattr(socket, "TCP_KEEPCNT", None), self.count), + ): + if option is not None: + options.append((socket.IPPROTO_TCP, option, value)) + return options + + +def resolve_tcp_keepalive() -> TCPKeepAlive: + """Read keepalive settings, falling back on invalid positive integers.""" + prefix = "UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + return TCPKeepAlive( + enabled=os.getenv(prefix, "1").strip().lower() not in ("0", "false"), + idle=_positive_int(prefix + "_IDLE", 60), + interval=_positive_int(prefix + "_INTERVAL", 30), + count=_positive_int(prefix + "_COUNT", 5), + ) + + +def _client_transport_options( + transport_type: Any, + keepalive: TCPKeepAlive | None, + transport_options: dict[str, Any], +) -> dict[str, Any]: + settings = keepalive if keepalive is not None else resolve_tcp_keepalive() + options = dict(transport_options, socket_options=settings.socket_options()) + # HTTPX disables automatic environment proxy discovery with a custom transport. + # Reuse its URL patterns, including None mounts for NO_PROXY exclusions. + mounts = { + pattern: transport_type(proxy=proxy, **options) if proxy else None + for pattern, proxy in get_environment_proxies().items() + } + return {"transport": transport_type(**options), "mounts": mounts} + + +def create_http_client() -> httpx.Client: + """Construct the SDK's synchronous client with HTTPX defaults.""" + return httpx.Client(**_client_transport_options(httpx.HTTPTransport, None, {})) + + +def create_async_http_client( + *, + keepalive: TCPKeepAlive | None = None, + transport_options: dict[str, Any] | None = None, + **client_options: Any, +) -> httpx.AsyncClient: + """Construct an async client, applying TLS and pool settings to every route.""" + return httpx.AsyncClient( + **client_options, + **_client_transport_options( + httpx.AsyncHTTPTransport, keepalive, transport_options or {} + ), + ) diff --git a/src/unstructured_client/_hooks/custom/split_pdf_hook.py b/src/unstructured_client/_hooks/custom/split_pdf_hook.py index cafa29dc..e9660ee1 100644 --- a/src/unstructured_client/_hooks/custom/split_pdf_hook.py +++ b/src/unstructured_client/_hooks/custom/split_pdf_hook.py @@ -24,6 +24,9 @@ import pypdfium2 as pdfium # type: ignore[import-untyped] from unstructured_client._hooks.custom import form_utils, pdf_utils, request_utils +from unstructured_client._hooks.custom.http_transport import ( + create_async_http_client, resolve_tcp_keepalive, +) from unstructured_client._hooks.custom.common import UNSTRUCTURED_CLIENT_LOGGER_NAME from unstructured_client._hooks.custom.form_utils import ( PARTITION_FORM_CONCURRENCY_LEVEL_KEY, @@ -213,7 +216,7 @@ def _resolve_tls_config() -> tuple[Union[bool, str], Optional[Union[str, tuple[s """Resolve httpx TLS trust-store and mTLS client-certificate config from environment variables. - Returns a (verify, cert) tuple suitable for `httpx.AsyncClient(verify=..., cert=...)`. + Returns a (verify, cert) tuple for the HTTPX transport. Trust store (`verify`) — honors the same standard env vars other libraries use, so a single env-var setting applies uniformly across @@ -295,9 +298,10 @@ async def run_tasks( limits = _resolve_pool_limits() verify, cert = _resolve_tls_config() + keepalive = resolve_tcp_keepalive() logger.debug( - "split_pdf event=batch_async_start operation_id=%s chunk_count=%d concurrency=%d client_timeout=%s allow_failed=%s pool_max_connections=%s pool_max_keepalive=%s pool_keepalive_expiry=%s tls=%s", + "split_pdf event=batch_async_start operation_id=%s chunk_count=%d concurrency=%d client_timeout=%s allow_failed=%s pool_max_connections=%s pool_max_keepalive=%s pool_keepalive_expiry=%s tls=%s tcp_keepalive=%s tcp_keepalive_idle=%d tcp_keepalive_interval=%d tcp_keepalive_count=%d", operation_id, len(coroutines), concurrency_level, @@ -307,10 +311,15 @@ async def run_tasks( limits.max_keepalive_connections, limits.keepalive_expiry, _describe_tls_config(verify, cert), + keepalive.enabled, + keepalive.idle, + keepalive.interval, + keepalive.count, ) - async with httpx.AsyncClient( - timeout=client_timeout, limits=limits, verify=verify, cert=cert + async with create_async_http_client( + timeout=client_timeout, keepalive=keepalive, + transport_options={"limits": limits, "verify": verify, "cert": cert}, ) as client: armed_coroutines = [coro(async_client=client, limiter=limiter) for coro in coroutines] # type: ignore tasks = [ @@ -904,8 +913,9 @@ def _before_request_unlocked( plan_limits = _resolve_pool_limits() plan_verify, plan_cert = _resolve_tls_config() + plan_keepalive = resolve_tcp_keepalive() logger.info( - "split_pdf event=plan_created operation_id=%s filename=%s strategy=%s page_range=%s-%s page_count=%d split_size=%d chunk_count=%d concurrency=%d allow_failed=%s cache_mode=%s timeout_seconds=%s retry_config_mode=%s pool_max_connections=%d pool_max_keepalive=%d pool_keepalive_expiry=%.1fs tls=%s", + "split_pdf event=plan_created operation_id=%s filename=%s strategy=%s page_range=%s-%s page_count=%d split_size=%d chunk_count=%d concurrency=%d allow_failed=%s cache_mode=%s timeout_seconds=%s retry_config_mode=%s pool_max_connections=%d pool_max_keepalive=%d pool_keepalive_expiry=%.1fs tls=%s tcp_keepalive=%s tcp_keepalive_idle=%d tcp_keepalive_interval=%d tcp_keepalive_count=%d", operation_id, Path(pdf_file_meta["filename"]).name, form_data.get("strategy"), @@ -928,6 +938,10 @@ def _before_request_unlocked( plan_limits.max_keepalive_connections, plan_limits.keepalive_expiry, _describe_tls_config(plan_verify, plan_cert), + plan_keepalive.enabled, + plan_keepalive.idle, + plan_keepalive.interval, + plan_keepalive.count, ) self.pending_operation_ids[operation_id] = operation_id diff --git a/src/unstructured_client/sdk.py b/src/unstructured_client/sdk.py index 3671fa6e..d69b7997 100644 --- a/src/unstructured_client/sdk.py +++ b/src/unstructured_client/sdk.py @@ -1,15 +1,17 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" +"""Unstructured SDK entry point.""" from .basesdk import BaseSDK from .httpclient import AsyncHttpClient, ClientOwner, HttpClient, close_clients from .sdkconfiguration import SDKConfiguration from .utils.logger import Logger, get_default_logger from .utils.retries import RetryConfig -import httpx import importlib from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union, cast from unstructured_client import utils from unstructured_client._hooks import SDKHooks +from unstructured_client._hooks.custom.http_transport import ( + create_http_client, create_async_http_client, +) from unstructured_client.models import shared from unstructured_client.types import OptionalNullable, UNSET import weakref @@ -66,7 +68,7 @@ def __init__( """ client_supplied = True if client is None: - client = httpx.Client() + client = create_http_client() client_supplied = False assert issubclass( @@ -75,7 +77,7 @@ def __init__( async_client_supplied = True if async_client is None: - async_client = httpx.AsyncClient() + async_client = create_async_http_client() async_client_supplied = False if debug_logger is None: From eaf4a03866dca3dbe03cef967818559b722dc166 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:42:50 -0400 Subject: [PATCH 2/4] fix(transport): Bound keepalive settings and guard HTTPX internals --- CHANGELOG.md | 2 +- .../unit/test_tcp_keepalive.py | 49 ++++++++++++++++++- .../_hooks/custom/http_transport.py | 29 ++++++++--- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf175a9..462b249f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.46.3 ### Fixes -* 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. +* 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. * 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. ## 0.46.2 diff --git a/_test_unstructured_client/unit/test_tcp_keepalive.py b/_test_unstructured_client/unit/test_tcp_keepalive.py index 958a300c..be3995b1 100644 --- a/_test_unstructured_client/unit/test_tcp_keepalive.py +++ b/_test_unstructured_client/unit/test_tcp_keepalive.py @@ -5,6 +5,7 @@ import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread +from unittest.mock import patch import httpcore import httpx @@ -16,6 +17,8 @@ @pytest.fixture(autouse=True) def clean_env(monkeypatch): + if hasattr(transport, "_log_keepalive_unavailable"): + transport._log_keepalive_unavailable.cache_clear() for suffix in ("", "_IDLE", "_INTERVAL", "_COUNT"): monkeypatch.delenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + suffix, raising=False) monkeypatch.setenv("NO_PROXY", "*") @@ -50,6 +53,43 @@ def test_unit_invalid_value(monkeypatch, caplog, suffix, default, value): assert "using default" in caplog.text +@pytest.mark.parametrize("suffix,maximum,default", [ + ("IDLE", 32767, 60), ("INTERVAL", 32767, 30), ("COUNT", 127, 5), +]) +@pytest.mark.parametrize("above_maximum", [False, True]) +def test_unit_value_bounds(monkeypatch, caplog, suffix, maximum, default, above_maximum): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, + str(maximum + int(above_maximum))) + expected = default if above_maximum else maximum + assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == expected + assert ("using default" in caplog.text) == above_maximum + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_unit_missing_proxy_helper_client_options(monkeypatch, asynchronous): + monkeypatch.setattr(transport, "get_environment_proxies", None) + if asynchronous: + options = {"verify": False, "cert": None, "limits": httpx.Limits(max_connections=3)} + with patch.object(httpx, "AsyncClient") as constructor: + transport.create_async_http_client(timeout=42, transport_options=options) + constructor.assert_called_once_with(timeout=42, **options) + else: + with patch.object(httpx, "Client") as constructor: + transport.create_http_client() + constructor.assert_called_once_with() + + +def test_unit_missing_proxy_helper_logs_once(monkeypatch, caplog): + monkeypatch.setattr(transport, "get_environment_proxies", None) + caplog.set_level("DEBUG", logger=transport.logger.name) + with transport.create_http_client(): + pass + asyncio.run(transport.create_async_http_client().aclose()) + messages = [record for record in caplog.records if "TCP keepalive is unavailable" in record.message] + assert len(messages) == 1 + assert messages[0].levelname == "DEBUG" + + def test_unit_custom_values(monkeypatch): for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")): monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) @@ -81,15 +121,20 @@ def test_unit_supplied_clients_untouched(): asyncio.run(asynchronous.aclose()) +@pytest.mark.parametrize("missing_proxy_helper", [False, True]) @pytest.mark.parametrize("asynchronous", [False, True]) -def test_unit_environment_proxy_routes(monkeypatch, asynchronous): +def test_unit_environment_proxy_routes(monkeypatch, asynchronous, missing_proxy_helper): + if missing_proxy_helper: + monkeypatch.setattr(transport, "get_environment_proxies", None) monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:8123") monkeypatch.setenv("NO_PROXY", "excluded.example") client = transport.create_async_http_client() if asynchronous else transport.create_http_client() proxied = client._transport_for_url(httpx.URL("https://api.example")) assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy) assert proxied._pool._proxy_url.host == b"127.0.0.1" - assert proxied._pool._socket_options == transport.resolve_tcp_keepalive().socket_options() + if missing_proxy_helper: + assert client._transport._pool._socket_options is None + assert proxied._pool._socket_options is None assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport if asynchronous: asyncio.run(client.aclose()) diff --git a/src/unstructured_client/_hooks/custom/http_transport.py b/src/unstructured_client/_hooks/custom/http_transport.py index 97375abf..38cc37fc 100644 --- a/src/unstructured_client/_hooks/custom/http_transport.py +++ b/src/unstructured_client/_hooks/custom/http_transport.py @@ -1,24 +1,28 @@ -"""HTTP clients with TCP keepalive and HTTPX environment proxy routing.""" +"""HTTP clients with TCP keepalive for direct connections and environment proxy routing.""" import logging import os import socket from dataclasses import dataclass +from functools import lru_cache from typing import Any import httpx -from httpx._utils import get_environment_proxies +try: + from httpx._utils import get_environment_proxies +except ImportError: + get_environment_proxies = None # type: ignore[assignment] from .common import UNSTRUCTURED_CLIENT_LOGGER_NAME logger = logging.getLogger(UNSTRUCTURED_CLIENT_LOGGER_NAME) -def _positive_int(name: str, default: int) -> int: +def _positive_int(name: str, default: int, maximum: int) -> int: value = os.getenv(name, str(default)) try: parsed = int(value) - if parsed > 0: + if 1 <= parsed <= maximum: return parsed except ValueError: pass @@ -54,25 +58,34 @@ def socket_options(self) -> list[tuple[int, int, int]]: def resolve_tcp_keepalive() -> TCPKeepAlive: - """Read keepalive settings, falling back on invalid positive integers.""" + """Read keepalive settings, falling back on values outside the supported bounds.""" prefix = "UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" return TCPKeepAlive( enabled=os.getenv(prefix, "1").strip().lower() not in ("0", "false"), - idle=_positive_int(prefix + "_IDLE", 60), - interval=_positive_int(prefix + "_INTERVAL", 30), - count=_positive_int(prefix + "_COUNT", 5), + idle=_positive_int(prefix + "_IDLE", 60, 32767), + interval=_positive_int(prefix + "_INTERVAL", 30, 32767), + count=_positive_int(prefix + "_COUNT", 5, 127), ) +@lru_cache(maxsize=1) +def _log_keepalive_unavailable() -> None: + logger.debug("TCP keepalive is unavailable with this httpx version") + + def _client_transport_options( transport_type: Any, keepalive: TCPKeepAlive | None, transport_options: dict[str, Any], ) -> dict[str, Any]: + if get_environment_proxies is None: + _log_keepalive_unavailable() + return transport_options settings = keepalive if keepalive is not None else resolve_tcp_keepalive() options = dict(transport_options, socket_options=settings.socket_options()) # HTTPX disables automatic environment proxy discovery with a custom transport. # Reuse its URL patterns, including None mounts for NO_PROXY exclusions. + # HTTP and SOCKS proxy connections do not apply these socket options. mounts = { pattern: transport_type(proxy=proxy, **options) if proxy else None for pattern, proxy in get_environment_proxies().items() From 473b43795dc286c2ad9653c72deb35b53a67b4d0 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:48:11 -0400 Subject: [PATCH 3/4] refactor(transport): Simplify keepalive client factories --- .../unit/test_split_collection_urls.py | 7 +- .../unit/test_split_pdf_hook.py | 4 +- .../unit/test_tcp_keepalive.py | 176 +++++++++--------- .../_hooks/custom/http_transport.py | 89 +++++---- .../_hooks/custom/split_pdf_hook.py | 19 +- src/unstructured_client/sdk.py | 2 +- 6 files changed, 149 insertions(+), 148 deletions(-) diff --git a/_test_unstructured_client/unit/test_split_collection_urls.py b/_test_unstructured_client/unit/test_split_collection_urls.py index 3320e28d..99c23857 100644 --- a/_test_unstructured_client/unit/test_split_collection_urls.py +++ b/_test_unstructured_client/unit/test_split_collection_urls.py @@ -6,6 +6,7 @@ from pypdf import PdfWriter from unstructured_client import UnstructuredClient +from unstructured_client._hooks.custom import split_pdf_hook from unstructured_client._hooks.custom.split_pdf_hook import _get_collection_url from unstructured_client.models import operations, shared from unstructured_client.utils import BackoffStrategy, RetryConfig @@ -60,9 +61,9 @@ def respond(request): ) # Split chunks create their own async HTTP client; keep every request offline. monkeypatch.setattr( - httpx, - "AsyncClient", - lambda **kwargs: original_async_client(**dict(kwargs, transport=transport, mounts={})), + split_pdf_hook, + "create_async_http_client", + lambda **kwargs: original_async_client(**kwargs, transport=transport), ) return sdk, sent, sync_client, async_client diff --git a/_test_unstructured_client/unit/test_split_pdf_hook.py b/_test_unstructured_client/unit/test_split_pdf_hook.py index a1a541ee..0a209723 100644 --- a/_test_unstructured_client/unit/test_split_pdf_hook.py +++ b/_test_unstructured_client/unit/test_split_pdf_hook.py @@ -625,14 +625,13 @@ def test_unit_resolve_tls_config_client_cert_and_key_split( async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_transport( monkeypatch: pytest.MonkeyPatch, tmp_path ): - """Split requests apply TLS and pool settings to the transport.""" + """Split requests apply TLS settings to the transport.""" ca_bundle = tmp_path / "ca.pem" ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") monkeypatch.setenv("SSL_CERT_FILE", str(ca_bundle)) monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.pem") monkeypatch.setenv("NO_PROXY", "*") - monkeypatch.setenv("UNSTRUCTURED_CLIENT_MAX_CONNECTIONS", "17") captured: dict = {} class _MockAsyncClient: @@ -660,7 +659,6 @@ async def __aexit__(self, *exc): assert captured["transport"] is transport.return_value assert kwargs["verify"] == str(ca_bundle) assert kwargs["cert"] == "/etc/ssl/client.pem" - assert kwargs["limits"].max_connections == 17 assert kwargs["socket_options"] diff --git a/_test_unstructured_client/unit/test_tcp_keepalive.py b/_test_unstructured_client/unit/test_tcp_keepalive.py index be3995b1..c625a491 100644 --- a/_test_unstructured_client/unit/test_tcp_keepalive.py +++ b/_test_unstructured_client/unit/test_tcp_keepalive.py @@ -2,7 +2,7 @@ import asyncio import socket -import sys +from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from unittest.mock import patch @@ -17,61 +17,93 @@ @pytest.fixture(autouse=True) def clean_env(monkeypatch): - if hasattr(transport, "_log_keepalive_unavailable"): - transport._log_keepalive_unavailable.cache_clear() for suffix in ("", "_IDLE", "_INTERVAL", "_COUNT"): monkeypatch.delenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + suffix, raising=False) monkeypatch.setenv("NO_PROXY", "*") -@pytest.mark.parametrize("platform", ["linux", "macos", "macos_old", "windows"]) -def test_unit_platform_options(monkeypatch, platform): - for name in ("TCP_KEEPIDLE", "TCP_KEEPALIVE", "TCP_KEEPINTVL", "TCP_KEEPCNT"): - monkeypatch.delattr(socket, name, raising=False) - expected = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] - if platform != "windows": - monkeypatch.setattr(socket, "TCP_KEEPIDLE" if platform == "linux" else "TCP_KEEPALIVE", 100, raising=False) - expected.append((socket.IPPROTO_TCP, 100, 60)) - if platform != "macos_old": - monkeypatch.setattr(socket, "TCP_KEEPINTVL", 101, raising=False) - monkeypatch.setattr(socket, "TCP_KEEPCNT", 102, raising=False) - expected += [(socket.IPPROTO_TCP, 101, 30), (socket.IPPROTO_TCP, 102, 5)] - assert transport.resolve_tcp_keepalive().socket_options() == expected +DEFAULTS = transport.TCPKeepAlive(enabled=True) +BASE_OPTIONS = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] -@pytest.mark.parametrize("value", ["0", "false", "False", " FALSE "]) -def test_unit_disabled(monkeypatch, value): - monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE", value) - assert transport.resolve_tcp_keepalive().socket_options() == [] +def _close(client): + if isinstance(client, httpx.AsyncClient): + asyncio.run(client.aclose()) + else: + client.close() -@pytest.mark.parametrize("suffix,default", [("IDLE", 60), ("INTERVAL", 30), ("COUNT", 5)]) -@pytest.mark.parametrize("value", ["0", "-1", "bad", "1.5", ""]) -def test_unit_invalid_value(monkeypatch, caplog, suffix, default, value): - monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) - assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == default - assert "using default" in caplog.text +@contextmanager +def local_server(handler): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.mark.parametrize("attrs_to_set,expected_options", [ + ({"TCP_KEEPIDLE": 100, "TCP_KEEPINTVL": 101, "TCP_KEEPCNT": 102}, + [(socket.IPPROTO_TCP, 100, DEFAULTS.idle), + (socket.IPPROTO_TCP, 101, DEFAULTS.interval), + (socket.IPPROTO_TCP, 102, DEFAULTS.count)]), + ({"TCP_KEEPALIVE": 100, "TCP_KEEPINTVL": 101, "TCP_KEEPCNT": 102}, + [(socket.IPPROTO_TCP, 100, DEFAULTS.idle), + (socket.IPPROTO_TCP, 101, DEFAULTS.interval), + (socket.IPPROTO_TCP, 102, DEFAULTS.count)]), + ({"TCP_KEEPALIVE": 100}, [(socket.IPPROTO_TCP, 100, DEFAULTS.idle)]), + ({}, []), +], ids=["linux", "macos", "macos_old", "windows"]) +def test_unit_platform_options(monkeypatch, attrs_to_set, expected_options): + for name in ("TCP_KEEPIDLE", "TCP_KEEPALIVE", "TCP_KEEPINTVL", "TCP_KEEPCNT"): + monkeypatch.delattr(socket, name, raising=False) + for name, value in attrs_to_set.items(): + monkeypatch.setattr(socket, name, value, raising=False) + assert transport.resolve_tcp_keepalive().socket_options() == BASE_OPTIONS + expected_options -@pytest.mark.parametrize("suffix,maximum,default", [ - ("IDLE", 32767, 60), ("INTERVAL", 32767, 30), ("COUNT", 127, 5), +@pytest.mark.parametrize("value", ["0", "false", "False", " FALSE "]) +def test_unit_disabled(monkeypatch, caplog, value): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE", value) + for suffix in ("IDLE", "INTERVAL", "COUNT"): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, "invalid") + settings = transport.resolve_tcp_keepalive() + assert settings.socket_options() == [] + assert settings.describe() == "off" + assert not caplog.records + + +@pytest.mark.parametrize("suffix,value,expected", [ + (suffix, value, expected) + for suffix, maximum in ( + ("IDLE", transport.MAX_IDLE), + ("INTERVAL", transport.MAX_INTERVAL), + ("COUNT", transport.MAX_COUNT), + ) + for value, expected in ( + *[(invalid, getattr(DEFAULTS, suffix.lower())) + for invalid in ("0", "-1", "bad", "1.5", "", str(maximum + 1))], + ("1", 1), + (str(maximum), maximum), + ) ]) -@pytest.mark.parametrize("above_maximum", [False, True]) -def test_unit_value_bounds(monkeypatch, caplog, suffix, maximum, default, above_maximum): - monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, - str(maximum + int(above_maximum))) - expected = default if above_maximum else maximum +def test_unit_value_bounds(monkeypatch, caplog, suffix, value, expected): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == expected - assert ("using default" in caplog.text) == above_maximum + assert ("using default" in caplog.text) == (value != str(expected)) @pytest.mark.parametrize("asynchronous", [False, True]) def test_unit_missing_proxy_helper_client_options(monkeypatch, asynchronous): monkeypatch.setattr(transport, "get_environment_proxies", None) if asynchronous: - options = {"verify": False, "cert": None, "limits": httpx.Limits(max_connections=3)} + options = {"verify": False, "cert": "client.pem", "limits": httpx.Limits(max_connections=3)} with patch.object(httpx, "AsyncClient") as constructor: - transport.create_async_http_client(timeout=42, transport_options=options) + transport.create_async_http_client(timeout=42, **options) constructor.assert_called_once_with(timeout=42, **options) else: with patch.object(httpx, "Client") as constructor: @@ -79,21 +111,12 @@ def test_unit_missing_proxy_helper_client_options(monkeypatch, asynchronous): constructor.assert_called_once_with() -def test_unit_missing_proxy_helper_logs_once(monkeypatch, caplog): - monkeypatch.setattr(transport, "get_environment_proxies", None) - caplog.set_level("DEBUG", logger=transport.logger.name) - with transport.create_http_client(): - pass - asyncio.run(transport.create_async_http_client().aclose()) - messages = [record for record in caplog.records if "TCP keepalive is unavailable" in record.message] - assert len(messages) == 1 - assert messages[0].levelname == "DEBUG" - - def test_unit_custom_values(monkeypatch): for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")): monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) - assert transport.resolve_tcp_keepalive() == transport.TCPKeepAlive(True, 5, 6, 7) + settings = transport.resolve_tcp_keepalive() + assert settings == transport.TCPKeepAlive(True, 5, 6, 7) + assert settings.describe() == "idle=5s interval=6s count=7" def test_unit_sdk_owned_clients(): @@ -101,8 +124,8 @@ def test_unit_sdk_owned_clients(): options = transport.resolve_tcp_keepalive().socket_options() assert sdk.sdk_configuration.client._transport.base_transport._pool._socket_options == options assert sdk.sdk_configuration.async_client._transport._pool._socket_options == options - sdk.sdk_configuration.client.close() - asyncio.run(sdk.sdk_configuration.async_client.aclose()) + _close(sdk.sdk_configuration.client) + _close(sdk.sdk_configuration.async_client) def test_unit_supplied_clients_untouched(): @@ -110,15 +133,11 @@ def test_unit_supplied_clients_untouched(): asynchronous = httpx.AsyncClient() sync_transport = sync._transport async_transport = asynchronous._transport - sdk = UnstructuredClient(client=sync, async_client=asynchronous) - assert sdk.sdk_configuration.client is sync - assert sdk.sdk_configuration.async_client is asynchronous + UnstructuredClient(client=sync, async_client=asynchronous) assert sync._transport.base_transport is sync_transport assert asynchronous._transport is async_transport - assert sync_transport._pool._socket_options is None - assert asynchronous._transport._pool._socket_options is None - sync.close() - asyncio.run(asynchronous.aclose()) + _close(sync) + _close(asynchronous) @pytest.mark.parametrize("missing_proxy_helper", [False, True]) @@ -132,21 +151,15 @@ def test_unit_environment_proxy_routes(monkeypatch, asynchronous, missing_proxy_ proxied = client._transport_for_url(httpx.URL("https://api.example")) assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy) assert proxied._pool._proxy_url.host == b"127.0.0.1" + assert proxied._pool._socket_options is None if missing_proxy_helper: assert client._transport._pool._socket_options is None - assert proxied._pool._socket_options is None assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport - if asynchronous: - asyncio.run(client.aclose()) - else: - client.close() + _close(client) @pytest.mark.parametrize("asynchronous", [False, True]) def test_unit_real_socket_keepalive(asynchronous): - if not hasattr(socket, "SO_KEEPALIVE"): - pytest.skip("SO_KEEPALIVE unavailable") - class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -156,20 +169,10 @@ def do_GET(self): self.end_headers() self.wfile.write(b"ok") - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - url = f"http://127.0.0.1:{server.server_port}" - def check(response): stream = response.extensions["network_stream"] sock = stream.get_extra_info("socket") - enabled = sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) - # Darwin reports the option bit mask rather than a normalized boolean. - if sys.platform == "darwin": - assert enabled != 0 - else: - assert enabled == 1 + assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) async def request(): async with transport.create_async_http_client() as client: @@ -177,7 +180,8 @@ async def request(): check(response) await response.aread() - try: + with local_server(Handler) as port: + url = f"http://127.0.0.1:{port}" if asynchronous: asyncio.run(request()) else: @@ -185,10 +189,6 @@ async def request(): with client.stream("GET", url) as response: check(response) response.read() - finally: - server.shutdown() - server.server_close() - thread.join() @pytest.mark.parametrize("asynchronous", [False, True]) @@ -200,17 +200,13 @@ def do_CONNECT(self): destinations.append(self.path) self.send_error(502) - server = ThreadingHTTPServer(("127.0.0.1", 0), ProxyHandler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{server.server_port}") - monkeypatch.setenv("NO_PROXY", "") - async def request(): async with transport.create_async_http_client() as client: await client.get("https://proxy-target.invalid/") - try: + with local_server(ProxyHandler) as port: + monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{port}") + monkeypatch.setenv("NO_PROXY", "") with pytest.raises(httpx.ProxyError, match="502"): if asynchronous: asyncio.run(request()) @@ -218,7 +214,3 @@ async def request(): with transport.create_http_client() as client: client.get("https://proxy-target.invalid/") assert destinations == ["proxy-target.invalid:443"] - finally: - server.shutdown() - server.server_close() - thread.join() diff --git a/src/unstructured_client/_hooks/custom/http_transport.py b/src/unstructured_client/_hooks/custom/http_transport.py index 38cc37fc..e6e99d29 100644 --- a/src/unstructured_client/_hooks/custom/http_transport.py +++ b/src/unstructured_client/_hooks/custom/http_transport.py @@ -1,24 +1,35 @@ -"""HTTP clients with TCP keepalive for direct connections and environment proxy routing.""" +"""Single factory for every HTTP client the SDK constructs. + +TCP keepalive applies to direct connections; environment proxy routing is preserved. +""" import logging import os import socket from dataclasses import dataclass -from functools import lru_cache from typing import Any import httpx + +from .common import UNSTRUCTURED_CLIENT_LOGGER_NAME + +logger = logging.getLogger(UNSTRUCTURED_CLIENT_LOGGER_NAME) + try: from httpx._utils import get_environment_proxies except ImportError: get_environment_proxies = None # type: ignore[assignment] + logger.debug("TCP keepalive is unavailable with this httpx version") -from .common import UNSTRUCTURED_CLIENT_LOGGER_NAME - -logger = logging.getLogger(UNSTRUCTURED_CLIENT_LOGGER_NAME) +DEFAULT_IDLE = 60 +DEFAULT_INTERVAL = 30 +DEFAULT_COUNT = 5 +MAX_IDLE = 32767 +MAX_INTERVAL = 32767 +MAX_COUNT = 127 -def _positive_int(name: str, default: int, maximum: int) -> int: +def _bounded_int(name: str, default: int, maximum: int) -> int: value = os.getenv(name, str(default)) try: parsed = int(value) @@ -35,18 +46,22 @@ class TCPKeepAlive: """Resolved TCP probe settings, in seconds and number of probes.""" enabled: bool - idle: int - interval: int - count: int + idle: int = DEFAULT_IDLE + interval: int = DEFAULT_INTERVAL + count: int = DEFAULT_COUNT + + def describe(self) -> str: + """Summarize the resolved probe settings for logging.""" + if not self.enabled: + return "off" + return f"idle={self.idle}s interval={self.interval}s count={self.count}" def socket_options(self) -> list[tuple[int, int, int]]: """Use only the TCP options available on the current platform.""" if not self.enabled: return [] options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] - idle_option = getattr(socket, "TCP_KEEPIDLE", None) - if idle_option is None: - idle_option = getattr(socket, "TCP_KEEPALIVE", None) + idle_option = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None) for option, value in ( (idle_option, self.idle), (getattr(socket, "TCP_KEEPINTVL", None), self.interval), @@ -60,34 +75,28 @@ def socket_options(self) -> list[tuple[int, int, int]]: def resolve_tcp_keepalive() -> TCPKeepAlive: """Read keepalive settings, falling back on values outside the supported bounds.""" prefix = "UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + if os.getenv(prefix, "1").strip().lower() in ("0", "false"): + return TCPKeepAlive(enabled=False) return TCPKeepAlive( - enabled=os.getenv(prefix, "1").strip().lower() not in ("0", "false"), - idle=_positive_int(prefix + "_IDLE", 60, 32767), - interval=_positive_int(prefix + "_INTERVAL", 30, 32767), - count=_positive_int(prefix + "_COUNT", 5, 127), + enabled=True, + idle=_bounded_int(prefix + "_IDLE", DEFAULT_IDLE, MAX_IDLE), + interval=_bounded_int(prefix + "_INTERVAL", DEFAULT_INTERVAL, MAX_INTERVAL), + count=_bounded_int(prefix + "_COUNT", DEFAULT_COUNT, MAX_COUNT), ) -@lru_cache(maxsize=1) -def _log_keepalive_unavailable() -> None: - logger.debug("TCP keepalive is unavailable with this httpx version") - - def _client_transport_options( transport_type: Any, - keepalive: TCPKeepAlive | None, transport_options: dict[str, Any], ) -> dict[str, Any]: if get_environment_proxies is None: - _log_keepalive_unavailable() return transport_options - settings = keepalive if keepalive is not None else resolve_tcp_keepalive() - options = dict(transport_options, socket_options=settings.socket_options()) + options = dict(transport_options, socket_options=resolve_tcp_keepalive().socket_options()) # HTTPX disables automatic environment proxy discovery with a custom transport. # Reuse its URL patterns, including None mounts for NO_PROXY exclusions. - # HTTP and SOCKS proxy connections do not apply these socket options. + # Proxied connections are not given keepalive. mounts = { - pattern: transport_type(proxy=proxy, **options) if proxy else None + pattern: transport_type(proxy=proxy, **transport_options) if proxy else None for pattern, proxy in get_environment_proxies().items() } return {"transport": transport_type(**options), "mounts": mounts} @@ -95,19 +104,29 @@ def _client_transport_options( def create_http_client() -> httpx.Client: """Construct the SDK's synchronous client with HTTPX defaults.""" - return httpx.Client(**_client_transport_options(httpx.HTTPTransport, None, {})) + return httpx.Client(**_client_transport_options(httpx.HTTPTransport, {})) def create_async_http_client( *, - keepalive: TCPKeepAlive | None = None, - transport_options: dict[str, Any] | None = None, - **client_options: Any, + timeout: Any = None, + limits: httpx.Limits | None = None, + verify: Any = True, + cert: Any = None, ) -> httpx.AsyncClient: - """Construct an async client, applying TLS and pool settings to every route.""" + """Construct an async client, applying TLS and pool settings to every route. + + A None timeout leaves HTTPX's default timeout in place. + """ + transport_options: dict[str, Any] = {} + if limits is not None: + transport_options["limits"] = limits + if verify is not True: + transport_options["verify"] = verify + if cert is not None: + transport_options["cert"] = cert + client_options = {} if timeout is None else {"timeout": timeout} return httpx.AsyncClient( **client_options, - **_client_transport_options( - httpx.AsyncHTTPTransport, keepalive, transport_options or {} - ), + **_client_transport_options(httpx.AsyncHTTPTransport, transport_options), ) diff --git a/src/unstructured_client/_hooks/custom/split_pdf_hook.py b/src/unstructured_client/_hooks/custom/split_pdf_hook.py index e9660ee1..c6cf69c9 100644 --- a/src/unstructured_client/_hooks/custom/split_pdf_hook.py +++ b/src/unstructured_client/_hooks/custom/split_pdf_hook.py @@ -298,10 +298,9 @@ async def run_tasks( limits = _resolve_pool_limits() verify, cert = _resolve_tls_config() - keepalive = resolve_tcp_keepalive() logger.debug( - "split_pdf event=batch_async_start operation_id=%s chunk_count=%d concurrency=%d client_timeout=%s allow_failed=%s pool_max_connections=%s pool_max_keepalive=%s pool_keepalive_expiry=%s tls=%s tcp_keepalive=%s tcp_keepalive_idle=%d tcp_keepalive_interval=%d tcp_keepalive_count=%d", + "split_pdf event=batch_async_start operation_id=%s chunk_count=%d concurrency=%d client_timeout=%s allow_failed=%s pool_max_connections=%s pool_max_keepalive=%s pool_keepalive_expiry=%s tls=%s tcp_keepalive=%s", operation_id, len(coroutines), concurrency_level, @@ -311,15 +310,11 @@ async def run_tasks( limits.max_keepalive_connections, limits.keepalive_expiry, _describe_tls_config(verify, cert), - keepalive.enabled, - keepalive.idle, - keepalive.interval, - keepalive.count, + resolve_tcp_keepalive().describe(), ) async with create_async_http_client( - timeout=client_timeout, keepalive=keepalive, - transport_options={"limits": limits, "verify": verify, "cert": cert}, + timeout=client_timeout, limits=limits, verify=verify, cert=cert, ) as client: armed_coroutines = [coro(async_client=client, limiter=limiter) for coro in coroutines] # type: ignore tasks = [ @@ -913,9 +908,8 @@ def _before_request_unlocked( plan_limits = _resolve_pool_limits() plan_verify, plan_cert = _resolve_tls_config() - plan_keepalive = resolve_tcp_keepalive() logger.info( - "split_pdf event=plan_created operation_id=%s filename=%s strategy=%s page_range=%s-%s page_count=%d split_size=%d chunk_count=%d concurrency=%d allow_failed=%s cache_mode=%s timeout_seconds=%s retry_config_mode=%s pool_max_connections=%d pool_max_keepalive=%d pool_keepalive_expiry=%.1fs tls=%s tcp_keepalive=%s tcp_keepalive_idle=%d tcp_keepalive_interval=%d tcp_keepalive_count=%d", + "split_pdf event=plan_created operation_id=%s filename=%s strategy=%s page_range=%s-%s page_count=%d split_size=%d chunk_count=%d concurrency=%d allow_failed=%s cache_mode=%s timeout_seconds=%s retry_config_mode=%s pool_max_connections=%d pool_max_keepalive=%d pool_keepalive_expiry=%.1fs tls=%s tcp_keepalive=%s", operation_id, Path(pdf_file_meta["filename"]).name, form_data.get("strategy"), @@ -938,10 +932,7 @@ def _before_request_unlocked( plan_limits.max_keepalive_connections, plan_limits.keepalive_expiry, _describe_tls_config(plan_verify, plan_cert), - plan_keepalive.enabled, - plan_keepalive.idle, - plan_keepalive.interval, - plan_keepalive.count, + resolve_tcp_keepalive().describe(), ) self.pending_operation_ids[operation_id] = operation_id diff --git a/src/unstructured_client/sdk.py b/src/unstructured_client/sdk.py index d69b7997..8a7021fe 100644 --- a/src/unstructured_client/sdk.py +++ b/src/unstructured_client/sdk.py @@ -1,4 +1,4 @@ -"""Unstructured SDK entry point.""" +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" from .basesdk import BaseSDK from .httpclient import AsyncHttpClient, ClientOwner, HttpClient, close_clients From b3f46233cd6af16a1ef5b4b50a3647a34ba4fd5c Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:57:39 -0400 Subject: [PATCH 4/4] test(transport): assert every keepalive option on direct connections --- _test_unstructured_client/unit/test_tcp_keepalive.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/_test_unstructured_client/unit/test_tcp_keepalive.py b/_test_unstructured_client/unit/test_tcp_keepalive.py index c625a491..cc1b2292 100644 --- a/_test_unstructured_client/unit/test_tcp_keepalive.py +++ b/_test_unstructured_client/unit/test_tcp_keepalive.py @@ -152,8 +152,8 @@ def test_unit_environment_proxy_routes(monkeypatch, asynchronous, missing_proxy_ assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy) assert proxied._pool._proxy_url.host == b"127.0.0.1" assert proxied._pool._socket_options is None - if missing_proxy_helper: - assert client._transport._pool._socket_options is None + expected = None if missing_proxy_helper else transport.resolve_tcp_keepalive().socket_options() + assert client._transport._pool._socket_options == expected assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport _close(client) @@ -172,7 +172,11 @@ def do_GET(self): def check(response): stream = response.extensions["network_stream"] sock = stream.get_extra_info("socket") - assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) + for level, option, value in transport.resolve_tcp_keepalive().socket_options(): + if (level, option) == (socket.SOL_SOCKET, socket.SO_KEEPALIVE): + assert sock.getsockopt(level, option) + else: + assert sock.getsockopt(level, option) == value async def request(): async with transport.create_async_http_client() as client: