Skip to content

Commit 7ab4de9

Browse files
fxdgearclaude
andauthored
feat: env-var configurable httpx pool + TLS in split_pdf_hook (0.45.0) (#344)
## What Adds env-var knobs for the `httpx.AsyncClient` used by `split_pdf_hook.run_tasks`, and ships them as **0.45.0**. Defaults match httpx — fully backward compatible. ### Connection-pool limits - `UNSTRUCTURED_CLIENT_MAX_CONNECTIONS` (default `100`) - `UNSTRUCTURED_CLIENT_MAX_KEEPALIVE_CONNECTIONS` (default `20`) - `UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY` (default `5.0` seconds) ### TLS trust store (server verification) Honors the standard env vars other Python tooling already respects, so a single setting applies uniformly: - `SSL_CERT_FILE` (stdlib `ssl` convention) - `REQUESTS_CA_BUNDLE` (requests / httpx-ecosystem convention; used if `SSL_CERT_FILE` is unset) ### mTLS client certificate - `UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT` — PEM file (httpx reads key from the same file by default) - `UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY` — optional, when cert and key live in separate files ### Observability - Extends the existing `split_pdf event=plan_created` INFO log to include the resolved pool values and trust-store / mTLS mode, so the active config is visible in production logs without leaking filesystem paths. ### Release - Bumps `_version.py` to `0.45.0`, adds a `0.45.0` `CHANGELOG.md` section, and appends a matching `RELEASES.md` entry. ## Why When the SDK runs in an environment where load balancing happens at TCP-connect time rather than per-request (a common Kubernetes setup with a plain ClusterIP and no service mesh), httpx's default keepalive pooling can lock onto a subset of backends. Newly added backends never receive traffic because existing connections stay glued to the originally-resolved set. Letting operators force shorter keepalive (e.g. `MAX_KEEPALIVE_CONNECTIONS=1` + a low `KEEPALIVE_EXPIRY`) makes the client re-establish connections more frequently, redistributing across the available backends. The TLS additions are for SDK consumers running behind corporate proxies with custom CAs, or against backends that require mTLS — previously they had to subclass / monkey-patch to get a custom `verify` or `cert` into the split-PDF client. ## How to use ```yaml env: # Pool reshuffling for connect-time-only LBs - name: UNSTRUCTURED_CLIENT_MAX_KEEPALIVE_CONNECTIONS value: "1" - name: UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY value: "30.0" # Custom trust store (standard env var, picked up by httpx, requests, ssl) - name: SSL_CERT_FILE value: /etc/ssl/internal-ca-bundle.pem # mTLS - name: UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT value: /etc/ssl/client.crt - name: UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY value: /etc/ssl/client.key ``` --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5e6f238 commit 7ab4de9

5 files changed

Lines changed: 294 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 0.45.0
2+
3+
### Features
4+
* Make the split-PDF `httpx.AsyncClient` connection-pool limits configurable via env vars: `UNSTRUCTURED_CLIENT_MAX_CONNECTIONS` (default `100`), `UNSTRUCTURED_CLIENT_MAX_KEEPALIVE_CONNECTIONS` (default `20`), and `UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY` (default `5.0`s). Defaults match httpx, so behavior is unchanged unless set. Useful when deploying behind a connect-time-only load balancer (e.g. Kubernetes ClusterIP without a mesh) where shorter keepalives force connections to redistribute across backend pods.
5+
* Honor the standard `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env vars to point the split-PDF `httpx.AsyncClient` at a custom trust store, so a single env-var setting applies uniformly across Python tooling.
6+
* Add `UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT` and `UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY` env vars to wire an mTLS client certificate into the split-PDF `httpx.AsyncClient` (single PEM, or split cert + key files).
7+
* Extend the split-PDF `event=plan_created` log to include the resolved pool limits and trust-store / mTLS mode so the active config is visible in production logs.
8+
19
## 0.44.1
210

311
### Features

RELEASES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,3 +1231,13 @@ Based on:
12311231
- [python v0.44.1] .
12321232
### Releases
12331233
- [PyPI v0.44.1] https://pypi.org/project/unstructured-client/0.44.1 - .
1234+
1235+
## 2026-06-05 00:00:00
1236+
### Changes
1237+
Based on:
1238+
- OpenAPI Doc
1239+
- Speakeasy CLI 1.601.0 (2.680.0) https://github.com/speakeasy-api/speakeasy
1240+
### Generated
1241+
- [python v0.45.0] .
1242+
### Releases
1243+
- [PyPI v0.45.0] https://pypi.org/project/unstructured-client/0.45.0 - .

_test_unstructured_client/unit/test_split_pdf_hook.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,184 @@ async def test_remaining_tasks_cancelled_when_fails_disallowed():
482482
assert len(tasks) > cancelled_counter["cancelled"] > 0
483483

484484

485+
@pytest.mark.asyncio
486+
async def test_unit_run_tasks_pool_limits_configurable_via_env(
487+
monkeypatch: pytest.MonkeyPatch,
488+
):
489+
"""Env vars override the httpx.AsyncClient connection-pool limits.
490+
491+
Operators running the SDK in a Kubernetes Deployment with
492+
connect-time-only load balancing (kube-proxy ClusterIP, meshless)
493+
need to be able to shrink the keepalive pool so connections recycle
494+
frequently and redistribute across backend pods.
495+
"""
496+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_MAX_CONNECTIONS", "7")
497+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_MAX_KEEPALIVE_CONNECTIONS", "1")
498+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY", "30.0")
499+
500+
captured: dict[str, httpx.Limits] = {}
501+
real_async_client = httpx.AsyncClient
502+
503+
def _capturing_async_client(*args, **kwargs):
504+
captured["limits"] = kwargs.get("limits")
505+
return real_async_client(*args, **kwargs)
506+
507+
with patch(
508+
"unstructured_client._hooks.custom.split_pdf_hook.httpx.AsyncClient",
509+
side_effect=_capturing_async_client,
510+
):
511+
await run_tasks(
512+
[partial(_request_mock, fails=False, content="ok")],
513+
allow_failed=True,
514+
)
515+
516+
limits = captured["limits"]
517+
assert isinstance(limits, httpx.Limits)
518+
assert limits.max_connections == 7
519+
assert limits.max_keepalive_connections == 1
520+
assert limits.keepalive_expiry == 30.0
521+
522+
523+
_TLS_ENV_VARS = (
524+
"SSL_CERT_FILE",
525+
"REQUESTS_CA_BUNDLE",
526+
"UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT",
527+
"UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY",
528+
)
529+
530+
531+
def test_unit_resolve_tls_config_defaults_unchanged_without_env(
532+
monkeypatch: pytest.MonkeyPatch,
533+
):
534+
"""No env vars set → verify=True, cert=None (httpx defaults). Backward
535+
compatibility check — callers that don't opt into TLS config see no
536+
behavior change."""
537+
from unstructured_client._hooks.custom.split_pdf_hook import _resolve_tls_config
538+
539+
for var in _TLS_ENV_VARS:
540+
monkeypatch.delenv(var, raising=False)
541+
542+
verify, cert = _resolve_tls_config()
543+
assert verify is True
544+
assert cert is None
545+
546+
547+
def test_unit_resolve_tls_config_ssl_cert_file_from_env(
548+
monkeypatch: pytest.MonkeyPatch, tmp_path
549+
):
550+
"""SSL_CERT_FILE (stdlib ssl convention) → verify=<path>. Use case:
551+
internal CA bundle shared with other Python tooling."""
552+
from unstructured_client._hooks.custom.split_pdf_hook import _resolve_tls_config
553+
554+
for var in _TLS_ENV_VARS:
555+
monkeypatch.delenv(var, raising=False)
556+
557+
ca_bundle = tmp_path / "custom-ca.pem"
558+
ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
559+
monkeypatch.setenv("SSL_CERT_FILE", str(ca_bundle))
560+
561+
verify, cert = _resolve_tls_config()
562+
assert verify == str(ca_bundle)
563+
assert cert is None
564+
565+
566+
def test_unit_resolve_tls_config_requests_ca_bundle_from_env(
567+
monkeypatch: pytest.MonkeyPatch, tmp_path
568+
):
569+
"""REQUESTS_CA_BUNDLE (requests/httpx-ecosystem convention) → verify=<path>."""
570+
from unstructured_client._hooks.custom.split_pdf_hook import _resolve_tls_config
571+
572+
for var in _TLS_ENV_VARS:
573+
monkeypatch.delenv(var, raising=False)
574+
575+
ca_bundle = tmp_path / "custom-ca.pem"
576+
ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
577+
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(ca_bundle))
578+
579+
verify, cert = _resolve_tls_config()
580+
assert verify == str(ca_bundle)
581+
assert cert is None
582+
583+
584+
def test_unit_resolve_tls_config_ssl_cert_file_wins_over_requests_ca_bundle(
585+
monkeypatch: pytest.MonkeyPatch,
586+
):
587+
"""If both standard env vars are set, SSL_CERT_FILE takes precedence —
588+
it's the lower-level stdlib convention."""
589+
from unstructured_client._hooks.custom.split_pdf_hook import _resolve_tls_config
590+
591+
monkeypatch.setenv("SSL_CERT_FILE", "/etc/ssl/stdlib-ca.pem")
592+
monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/etc/ssl/requests-ca.pem")
593+
594+
verify, _ = _resolve_tls_config()
595+
assert verify == "/etc/ssl/stdlib-ca.pem"
596+
597+
598+
def test_unit_resolve_tls_config_client_cert_only(monkeypatch: pytest.MonkeyPatch):
599+
"""UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT alone → cert=<path>. httpx will
600+
read the private key from the same PEM file."""
601+
from unstructured_client._hooks.custom.split_pdf_hook import _resolve_tls_config
602+
603+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.pem")
604+
monkeypatch.delenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY", raising=False)
605+
606+
verify, cert = _resolve_tls_config()
607+
assert cert == "/etc/ssl/client.pem"
608+
609+
610+
def test_unit_resolve_tls_config_client_cert_and_key_split(
611+
monkeypatch: pytest.MonkeyPatch,
612+
):
613+
"""Both _CLIENT_CERT and _CLIENT_KEY → cert=(cert_path, key_path). For
614+
PKI setups where cert and key live in separate files."""
615+
from unstructured_client._hooks.custom.split_pdf_hook import _resolve_tls_config
616+
617+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.crt")
618+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY", "/etc/ssl/client.key")
619+
620+
verify, cert = _resolve_tls_config()
621+
assert cert == ("/etc/ssl/client.crt", "/etc/ssl/client.key")
622+
623+
624+
@pytest.mark.asyncio
625+
async def test_unit_run_tasks_forwards_tls_config_to_httpx_async_client(
626+
monkeypatch: pytest.MonkeyPatch, tmp_path
627+
):
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."""
632+
ca_bundle = tmp_path / "ca.pem"
633+
ca_bundle.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
634+
monkeypatch.setenv("SSL_CERT_FILE", str(ca_bundle))
635+
monkeypatch.setenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT", "/etc/ssl/client.pem")
636+
637+
captured: dict = {}
638+
639+
class _MockAsyncClient:
640+
def __init__(self, *args, **kwargs):
641+
captured["verify"] = kwargs.get("verify")
642+
captured["cert"] = kwargs.get("cert")
643+
644+
async def __aenter__(self):
645+
return self
646+
647+
async def __aexit__(self, *exc):
648+
return False
649+
650+
with patch(
651+
"unstructured_client._hooks.custom.split_pdf_hook.httpx.AsyncClient",
652+
new=_MockAsyncClient,
653+
):
654+
await run_tasks(
655+
[partial(_request_mock, fails=False, content="ok")],
656+
allow_failed=True,
657+
)
658+
659+
assert captured["verify"] == str(ca_bundle)
660+
assert captured["cert"] == "/etc/ssl/client.pem"
661+
662+
485663
@patch("unstructured_client._hooks.custom.form_utils.Path")
486664
def test_unit_get_split_pdf_cache_tmp_data_dir_uses_dir_from_form_data(mock_path: MagicMock):
487665
"""Test get_split_pdf_cache_tmp_data_dir uses the directory from the form data."""

src/unstructured_client/_hooks/custom/split_pdf_hook.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,84 @@ async def _order_keeper(index: int, coro: Awaitable) -> Tuple[int, httpx.Respons
177177
return index, response
178178

179179

180+
def _resolve_pool_limits() -> httpx.Limits:
181+
"""Resolve httpx connection-pool limits from environment variables.
182+
183+
Defaults match httpx's built-in defaults (max_connections=100,
184+
max_keepalive_connections=20, keepalive_expiry=5.0) so behavior is
185+
unchanged for callers that do not set the env vars. Operators running
186+
the SDK inside a Kubernetes Deployment that load-balances only at
187+
TCP-connect time (e.g. kube-proxy ClusterIP, no service mesh) can
188+
lower these values to force frequent reconnects and redistribute
189+
traffic across backend pods.
190+
"""
191+
return httpx.Limits(
192+
max_connections=int(os.getenv("UNSTRUCTURED_CLIENT_MAX_CONNECTIONS", "100")),
193+
max_keepalive_connections=int(
194+
os.getenv("UNSTRUCTURED_CLIENT_MAX_KEEPALIVE_CONNECTIONS", "20")
195+
),
196+
keepalive_expiry=float(
197+
os.getenv("UNSTRUCTURED_CLIENT_KEEPALIVE_EXPIRY", "5.0")
198+
),
199+
)
200+
201+
202+
def _resolve_tls_config() -> tuple[Union[bool, str], Optional[Union[str, tuple[str, str]]]]:
203+
"""Resolve httpx TLS trust-store and mTLS client-certificate config
204+
from environment variables.
205+
206+
Returns a (verify, cert) tuple suitable for `httpx.AsyncClient(verify=..., cert=...)`.
207+
208+
Trust store (`verify`) — honors the same standard env vars other
209+
libraries use, so a single env-var setting applies uniformly across
210+
tools:
211+
- `SSL_CERT_FILE` (path): stdlib `ssl` convention.
212+
- `REQUESTS_CA_BUNDLE` (path): `requests` / `httpx`-ecosystem
213+
convention. Checked if `SSL_CERT_FILE` is unset.
214+
- Otherwise: `True` (httpx default — use system trust store).
215+
216+
mTLS client certificate (`cert`):
217+
- `UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT` (path): PEM file. By default
218+
httpx will read the private key from the same file.
219+
- `UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY` (path, optional): use this
220+
separate key file. Required only if cert and key are split.
221+
- Otherwise: `None`.
222+
223+
Defaults match httpx's built-in defaults so behavior is unchanged for
224+
callers that don't set any of these variables.
225+
"""
226+
verify: Union[bool, str] = (
227+
os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE") or True
228+
)
229+
230+
cert: Optional[Union[str, tuple[str, str]]] = None
231+
if client_cert := os.getenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_CERT"):
232+
if client_key := os.getenv("UNSTRUCTURED_CLIENT_TLS_CLIENT_KEY"):
233+
cert = (client_cert, client_key)
234+
else:
235+
cert = client_cert
236+
237+
return verify, cert
238+
239+
240+
def _describe_tls_config(
241+
verify: Union[bool, str], cert: Optional[Union[str, tuple[str, str]]]
242+
) -> str:
243+
"""Short human-readable summary of the TLS config, safe for log output.
244+
Emits "system-trust" / "custom-ca-bundle" rather than the actual file
245+
path, so logs don't leak filesystem layout."""
246+
verify_desc = "system-trust" if verify is True else "custom-ca-bundle"
247+
248+
if cert is None:
249+
cert_desc = "none"
250+
elif isinstance(cert, tuple):
251+
cert_desc = "cert+key"
252+
else:
253+
cert_desc = "cert-only"
254+
255+
return f"trust_store={verify_desc} mtls_cert={cert_desc}"
256+
257+
180258
async def run_tasks(
181259
coroutines: list[partial[Coroutine[Any, Any, httpx.Response]]],
182260
allow_failed: bool = False,
@@ -205,16 +283,25 @@ async def run_tasks(
205283
client_timeout_minutes = int(timeout_var)
206284
client_timeout = httpx.Timeout(60 * client_timeout_minutes)
207285

286+
limits = _resolve_pool_limits()
287+
verify, cert = _resolve_tls_config()
288+
208289
logger.debug(
209-
"split_pdf event=batch_async_start operation_id=%s chunk_count=%d concurrency=%d client_timeout=%s allow_failed=%s",
290+
"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",
210291
operation_id,
211292
len(coroutines),
212293
concurrency_level,
213294
client_timeout,
214295
allow_failed,
296+
limits.max_connections,
297+
limits.max_keepalive_connections,
298+
limits.keepalive_expiry,
299+
_describe_tls_config(verify, cert),
215300
)
216301

217-
async with httpx.AsyncClient(timeout=client_timeout) as client:
302+
async with httpx.AsyncClient(
303+
timeout=client_timeout, limits=limits, verify=verify, cert=cert
304+
) as client:
218305
armed_coroutines = [coro(async_client=client, limiter=limiter) for coro in coroutines] # type: ignore
219306
tasks = [
220307
asyncio.create_task(_order_keeper(index, coro))
@@ -770,8 +857,10 @@ def _before_request_unlocked(
770857
)
771858
self.coroutines_to_execute[operation_id].append(coroutine)
772859

860+
plan_limits = _resolve_pool_limits()
861+
plan_verify, plan_cert = _resolve_tls_config()
773862
logger.info(
774-
"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",
863+
"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",
775864
operation_id,
776865
Path(pdf_file_meta["filename"]).name,
777866
form_data.get("strategy"),
@@ -790,6 +879,10 @@ def _before_request_unlocked(
790879
self._retry_config_observability_mode(
791880
self.operation_retry_configs.get(operation_id),
792881
),
882+
plan_limits.max_connections,
883+
plan_limits.max_keepalive_connections,
884+
plan_limits.keepalive_expiry,
885+
_describe_tls_config(plan_verify, plan_cert),
793886
)
794887

795888
self.pending_operation_ids[operation_id] = operation_id

src/unstructured_client/_version.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import importlib.metadata
44

55
__title__: str = "unstructured-client"
6-
__version__: str = "0.44.1"
6+
__version__: str = "0.45.0"
77
__openapi_doc_version__: str = "1.2.31"
88
__gen_version__: str = "2.680.0"
9-
__user_agent__: str = "speakeasy-sdk/python 0.44.1 2.680.0 1.2.31 unstructured-client"
9+
__user_agent__: str = "speakeasy-sdk/python 0.45.0 2.680.0 1.2.31 unstructured-client"
1010

1111
try:
1212
if __package__ is not None:

0 commit comments

Comments
 (0)