Skip to content

Commit 473b437

Browse files
committed
refactor(transport): Simplify keepalive client factories
1 parent eaf4a03 commit 473b437

6 files changed

Lines changed: 149 additions & 148 deletions

File tree

‎_test_unstructured_client/unit/test_split_collection_urls.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pypdf import PdfWriter
77

88
from unstructured_client import UnstructuredClient
9+
from unstructured_client._hooks.custom import split_pdf_hook
910
from unstructured_client._hooks.custom.split_pdf_hook import _get_collection_url
1011
from unstructured_client.models import operations, shared
1112
from unstructured_client.utils import BackoffStrategy, RetryConfig
@@ -60,9 +61,9 @@ def respond(request):
6061
)
6162
# Split chunks create their own async HTTP client; keep every request offline.
6263
monkeypatch.setattr(
63-
httpx,
64-
"AsyncClient",
65-
lambda **kwargs: original_async_client(**dict(kwargs, transport=transport, mounts={})),
64+
split_pdf_hook,
65+
"create_async_http_client",
66+
lambda **kwargs: original_async_client(**kwargs, transport=transport),
6667
)
6768
return sdk, sent, sync_client, async_client
6869

‎_test_unstructured_client/unit/test_split_pdf_hook.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -625,14 +625,13 @@ def test_unit_resolve_tls_config_client_cert_and_key_split(
625625
async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_transport(
626626
monkeypatch: pytest.MonkeyPatch, tmp_path
627627
):
628-
"""Split requests apply TLS and pool settings to the transport."""
628+
"""Split requests apply TLS settings to the transport."""
629629
ca_bundle = tmp_path / "ca.pem"
630630
ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
631631
monkeypatch.setenv("SSL_CERT_FILE", str(ca_bundle))
632632
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.pem")
633633

634634
monkeypatch.setenv("NO_PROXY", "*")
635-
monkeypatch.setenv("UNSTRUCTURED_CLIENT_MAX_CONNECTIONS", "17")
636635
captured: dict = {}
637636

638637
class _MockAsyncClient:
@@ -660,7 +659,6 @@ async def __aexit__(self, *exc):
660659
assert captured["transport"] is transport.return_value
661660
assert kwargs["verify"] == str(ca_bundle)
662661
assert kwargs["cert"] == "/etc/ssl/client.pem"
663-
assert kwargs["limits"].max_connections == 17
664662
assert kwargs["socket_options"]
665663

666664

‎_test_unstructured_client/unit/test_tcp_keepalive.py‎

Lines changed: 84 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
import socket
5-
import sys
5+
from contextlib import contextmanager
66
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
77
from threading import Thread
88
from unittest.mock import patch
@@ -17,108 +17,127 @@
1717

1818
@pytest.fixture(autouse=True)
1919
def clean_env(monkeypatch):
20-
if hasattr(transport, "_log_keepalive_unavailable"):
21-
transport._log_keepalive_unavailable.cache_clear()
2220
for suffix in ("", "_IDLE", "_INTERVAL", "_COUNT"):
2321
monkeypatch.delenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE" + suffix, raising=False)
2422
monkeypatch.setenv("NO_PROXY", "*")
2523

2624

27-
@pytest.mark.parametrize("platform", ["linux", "macos", "macos_old", "windows"])
28-
def test_unit_platform_options(monkeypatch, platform):
29-
for name in ("TCP_KEEPIDLE", "TCP_KEEPALIVE", "TCP_KEEPINTVL", "TCP_KEEPCNT"):
30-
monkeypatch.delattr(socket, name, raising=False)
31-
expected = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
32-
if platform != "windows":
33-
monkeypatch.setattr(socket, "TCP_KEEPIDLE" if platform == "linux" else "TCP_KEEPALIVE", 100, raising=False)
34-
expected.append((socket.IPPROTO_TCP, 100, 60))
35-
if platform != "macos_old":
36-
monkeypatch.setattr(socket, "TCP_KEEPINTVL", 101, raising=False)
37-
monkeypatch.setattr(socket, "TCP_KEEPCNT", 102, raising=False)
38-
expected += [(socket.IPPROTO_TCP, 101, 30), (socket.IPPROTO_TCP, 102, 5)]
39-
assert transport.resolve_tcp_keepalive().socket_options() == expected
25+
DEFAULTS = transport.TCPKeepAlive(enabled=True)
26+
BASE_OPTIONS = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
4027

4128

42-
@pytest.mark.parametrize("value", ["0", "false", "False", " FALSE "])
43-
def test_unit_disabled(monkeypatch, value):
44-
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE", value)
45-
assert transport.resolve_tcp_keepalive().socket_options() == []
29+
def _close(client):
30+
if isinstance(client, httpx.AsyncClient):
31+
asyncio.run(client.aclose())
32+
else:
33+
client.close()
4634

4735

48-
@pytest.mark.parametrize("suffix,default", [("IDLE", 60), ("INTERVAL", 30), ("COUNT", 5)])
49-
@pytest.mark.parametrize("value", ["0", "-1", "bad", "1.5", ""])
50-
def test_unit_invalid_value(monkeypatch, caplog, suffix, default, value):
51-
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value)
52-
assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == default
53-
assert "using default" in caplog.text
36+
@contextmanager
37+
def local_server(handler):
38+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
39+
thread = Thread(target=server.serve_forever, daemon=True)
40+
thread.start()
41+
try:
42+
yield server.server_port
43+
finally:
44+
server.shutdown()
45+
server.server_close()
46+
thread.join()
47+
48+
49+
@pytest.mark.parametrize("attrs_to_set,expected_options", [
50+
({"TCP_KEEPIDLE": 100, "TCP_KEEPINTVL": 101, "TCP_KEEPCNT": 102},
51+
[(socket.IPPROTO_TCP, 100, DEFAULTS.idle),
52+
(socket.IPPROTO_TCP, 101, DEFAULTS.interval),
53+
(socket.IPPROTO_TCP, 102, DEFAULTS.count)]),
54+
({"TCP_KEEPALIVE": 100, "TCP_KEEPINTVL": 101, "TCP_KEEPCNT": 102},
55+
[(socket.IPPROTO_TCP, 100, DEFAULTS.idle),
56+
(socket.IPPROTO_TCP, 101, DEFAULTS.interval),
57+
(socket.IPPROTO_TCP, 102, DEFAULTS.count)]),
58+
({"TCP_KEEPALIVE": 100}, [(socket.IPPROTO_TCP, 100, DEFAULTS.idle)]),
59+
({}, []),
60+
], ids=["linux", "macos", "macos_old", "windows"])
61+
def test_unit_platform_options(monkeypatch, attrs_to_set, expected_options):
62+
for name in ("TCP_KEEPIDLE", "TCP_KEEPALIVE", "TCP_KEEPINTVL", "TCP_KEEPCNT"):
63+
monkeypatch.delattr(socket, name, raising=False)
64+
for name, value in attrs_to_set.items():
65+
monkeypatch.setattr(socket, name, value, raising=False)
66+
assert transport.resolve_tcp_keepalive().socket_options() == BASE_OPTIONS + expected_options
5467

5568

56-
@pytest.mark.parametrize("suffix,maximum,default", [
57-
("IDLE", 32767, 60), ("INTERVAL", 32767, 30), ("COUNT", 127, 5),
69+
@pytest.mark.parametrize("value", ["0", "false", "False", " FALSE "])
70+
def test_unit_disabled(monkeypatch, caplog, value):
71+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE", value)
72+
for suffix in ("IDLE", "INTERVAL", "COUNT"):
73+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, "invalid")
74+
settings = transport.resolve_tcp_keepalive()
75+
assert settings.socket_options() == []
76+
assert settings.describe() == "off"
77+
assert not caplog.records
78+
79+
80+
@pytest.mark.parametrize("suffix,value,expected", [
81+
(suffix, value, expected)
82+
for suffix, maximum in (
83+
("IDLE", transport.MAX_IDLE),
84+
("INTERVAL", transport.MAX_INTERVAL),
85+
("COUNT", transport.MAX_COUNT),
86+
)
87+
for value, expected in (
88+
*[(invalid, getattr(DEFAULTS, suffix.lower()))
89+
for invalid in ("0", "-1", "bad", "1.5", "", str(maximum + 1))],
90+
("1", 1),
91+
(str(maximum), maximum),
92+
)
5893
])
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
94+
def test_unit_value_bounds(monkeypatch, caplog, suffix, value, expected):
95+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value)
6496
assert getattr(transport.resolve_tcp_keepalive(), suffix.lower()) == expected
65-
assert ("using default" in caplog.text) == above_maximum
97+
assert ("using default" in caplog.text) == (value != str(expected))
6698

6799

68100
@pytest.mark.parametrize("asynchronous", [False, True])
69101
def test_unit_missing_proxy_helper_client_options(monkeypatch, asynchronous):
70102
monkeypatch.setattr(transport, "get_environment_proxies", None)
71103
if asynchronous:
72-
options = {"verify": False, "cert": None, "limits": httpx.Limits(max_connections=3)}
104+
options = {"verify": False, "cert": "client.pem", "limits": httpx.Limits(max_connections=3)}
73105
with patch.object(httpx, "AsyncClient") as constructor:
74-
transport.create_async_http_client(timeout=42, transport_options=options)
106+
transport.create_async_http_client(timeout=42, **options)
75107
constructor.assert_called_once_with(timeout=42, **options)
76108
else:
77109
with patch.object(httpx, "Client") as constructor:
78110
transport.create_http_client()
79111
constructor.assert_called_once_with()
80112

81113

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-
93114
def test_unit_custom_values(monkeypatch):
94115
for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")):
95116
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value)
96-
assert transport.resolve_tcp_keepalive() == transport.TCPKeepAlive(True, 5, 6, 7)
117+
settings = transport.resolve_tcp_keepalive()
118+
assert settings == transport.TCPKeepAlive(True, 5, 6, 7)
119+
assert settings.describe() == "idle=5s interval=6s count=7"
97120

98121

99122
def test_unit_sdk_owned_clients():
100123
sdk = UnstructuredClient()
101124
options = transport.resolve_tcp_keepalive().socket_options()
102125
assert sdk.sdk_configuration.client._transport.base_transport._pool._socket_options == options
103126
assert sdk.sdk_configuration.async_client._transport._pool._socket_options == options
104-
sdk.sdk_configuration.client.close()
105-
asyncio.run(sdk.sdk_configuration.async_client.aclose())
127+
_close(sdk.sdk_configuration.client)
128+
_close(sdk.sdk_configuration.async_client)
106129

107130

108131
def test_unit_supplied_clients_untouched():
109132
sync = httpx.Client()
110133
asynchronous = httpx.AsyncClient()
111134
sync_transport = sync._transport
112135
async_transport = asynchronous._transport
113-
sdk = UnstructuredClient(client=sync, async_client=asynchronous)
114-
assert sdk.sdk_configuration.client is sync
115-
assert sdk.sdk_configuration.async_client is asynchronous
136+
UnstructuredClient(client=sync, async_client=asynchronous)
116137
assert sync._transport.base_transport is sync_transport
117138
assert asynchronous._transport is async_transport
118-
assert sync_transport._pool._socket_options is None
119-
assert asynchronous._transport._pool._socket_options is None
120-
sync.close()
121-
asyncio.run(asynchronous.aclose())
139+
_close(sync)
140+
_close(asynchronous)
122141

123142

124143
@pytest.mark.parametrize("missing_proxy_helper", [False, True])
@@ -132,21 +151,15 @@ def test_unit_environment_proxy_routes(monkeypatch, asynchronous, missing_proxy_
132151
proxied = client._transport_for_url(httpx.URL("https://api.example"))
133152
assert isinstance(proxied._pool, httpcore.AsyncHTTPProxy if asynchronous else httpcore.HTTPProxy)
134153
assert proxied._pool._proxy_url.host == b"127.0.0.1"
154+
assert proxied._pool._socket_options is None
135155
if missing_proxy_helper:
136156
assert client._transport._pool._socket_options is None
137-
assert proxied._pool._socket_options is None
138157
assert client._transport_for_url(httpx.URL("https://excluded.example")) is client._transport
139-
if asynchronous:
140-
asyncio.run(client.aclose())
141-
else:
142-
client.close()
158+
_close(client)
143159

144160

145161
@pytest.mark.parametrize("asynchronous", [False, True])
146162
def test_unit_real_socket_keepalive(asynchronous):
147-
if not hasattr(socket, "SO_KEEPALIVE"):
148-
pytest.skip("SO_KEEPALIVE unavailable")
149-
150163
class Handler(BaseHTTPRequestHandler):
151164
protocol_version = "HTTP/1.1"
152165

@@ -156,39 +169,26 @@ def do_GET(self):
156169
self.end_headers()
157170
self.wfile.write(b"ok")
158171

159-
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
160-
thread = Thread(target=server.serve_forever, daemon=True)
161-
thread.start()
162-
url = f"http://127.0.0.1:{server.server_port}"
163-
164172
def check(response):
165173
stream = response.extensions["network_stream"]
166174
sock = stream.get_extra_info("socket")
167-
enabled = sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE)
168-
# Darwin reports the option bit mask rather than a normalized boolean.
169-
if sys.platform == "darwin":
170-
assert enabled != 0
171-
else:
172-
assert enabled == 1
175+
assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE)
173176

174177
async def request():
175178
async with transport.create_async_http_client() as client:
176179
async with client.stream("GET", url) as response:
177180
check(response)
178181
await response.aread()
179182

180-
try:
183+
with local_server(Handler) as port:
184+
url = f"http://127.0.0.1:{port}"
181185
if asynchronous:
182186
asyncio.run(request())
183187
else:
184188
with transport.create_http_client() as client:
185189
with client.stream("GET", url) as response:
186190
check(response)
187191
response.read()
188-
finally:
189-
server.shutdown()
190-
server.server_close()
191-
thread.join()
192192

193193

194194
@pytest.mark.parametrize("asynchronous", [False, True])
@@ -200,25 +200,17 @@ def do_CONNECT(self):
200200
destinations.append(self.path)
201201
self.send_error(502)
202202

203-
server = ThreadingHTTPServer(("127.0.0.1", 0), ProxyHandler)
204-
thread = Thread(target=server.serve_forever, daemon=True)
205-
thread.start()
206-
monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{server.server_port}")
207-
monkeypatch.setenv("NO_PROXY", "")
208-
209203
async def request():
210204
async with transport.create_async_http_client() as client:
211205
await client.get("https://proxy-target.invalid/")
212206

213-
try:
207+
with local_server(ProxyHandler) as port:
208+
monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{port}")
209+
monkeypatch.setenv("NO_PROXY", "")
214210
with pytest.raises(httpx.ProxyError, match="502"):
215211
if asynchronous:
216212
asyncio.run(request())
217213
else:
218214
with transport.create_http_client() as client:
219215
client.get("https://proxy-target.invalid/")
220216
assert destinations == ["proxy-target.invalid:443"]
221-
finally:
222-
server.shutdown()
223-
server.server_close()
224-
thread.join()

0 commit comments

Comments
 (0)