diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c6a13f..462b249f 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 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_split_collection_urls.py b/_test_unstructured_client/unit/test_split_collection_urls.py index 99ab3fe9..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(transport=transport, **kwargs), + 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 be9ca472..0a209723 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,21 @@ 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 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", "*") 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 +645,21 @@ 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["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..cc1b2292 --- /dev/null +++ b/_test_unstructured_client/unit/test_tcp_keepalive.py @@ -0,0 +1,220 @@ +"""TCP keepalive configuration and transport integration.""" + +import asyncio +import socket +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from unittest.mock import patch + +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", "*") + + +DEFAULTS = transport.TCPKeepAlive(enabled=True) +BASE_OPTIONS = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] + + +def _close(client): + if isinstance(client, httpx.AsyncClient): + asyncio.run(client.aclose()) + else: + client.close() + + +@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("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), + ) +]) +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) == (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": "client.pem", "limits": httpx.Limits(max_connections=3)} + with patch.object(httpx, "AsyncClient") as constructor: + transport.create_async_http_client(timeout=42, **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_custom_values(monkeypatch): + for suffix, value in (("IDLE", "5"), ("INTERVAL", "6"), ("COUNT", "7")): + monkeypatch.setenv("UNSTRUCTURED_CLIENT_TCP_KEEPALIVE_" + suffix, value) + 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(): + 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 + _close(sdk.sdk_configuration.client) + _close(sdk.sdk_configuration.async_client) + + +def test_unit_supplied_clients_untouched(): + sync = httpx.Client() + asynchronous = httpx.AsyncClient() + sync_transport = sync._transport + async_transport = asynchronous._transport + UnstructuredClient(client=sync, async_client=asynchronous) + assert sync._transport.base_transport is sync_transport + assert asynchronous._transport is async_transport + _close(sync) + _close(asynchronous) + + +@pytest.mark.parametrize("missing_proxy_helper", [False, True]) +@pytest.mark.parametrize("asynchronous", [False, True]) +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 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) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_unit_real_socket_keepalive(asynchronous): + 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") + + def check(response): + stream = response.extensions["network_stream"] + sock = stream.get_extra_info("socket") + 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: + async with client.stream("GET", url) as response: + check(response) + await response.aread() + + with local_server(Handler) as port: + url = f"http://127.0.0.1:{port}" + if asynchronous: + asyncio.run(request()) + else: + with transport.create_http_client() as client: + with client.stream("GET", url) as response: + check(response) + response.read() + + +@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) + + async def request(): + async with transport.create_async_http_client() as client: + await client.get("https://proxy-target.invalid/") + + 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()) + else: + with transport.create_http_client() as client: + client.get("https://proxy-target.invalid/") + assert destinations == ["proxy-target.invalid:443"] 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..e6e99d29 --- /dev/null +++ b/src/unstructured_client/_hooks/custom/http_transport.py @@ -0,0 +1,132 @@ +"""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 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") + +DEFAULT_IDLE = 60 +DEFAULT_INTERVAL = 30 +DEFAULT_COUNT = 5 +MAX_IDLE = 32767 +MAX_INTERVAL = 32767 +MAX_COUNT = 127 + + +def _bounded_int(name: str, default: int, maximum: int) -> int: + value = os.getenv(name, str(default)) + try: + parsed = int(value) + if 1 <= parsed <= maximum: + 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 = 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) or 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 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=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), + ) + + +def _client_transport_options( + transport_type: Any, + transport_options: dict[str, Any], +) -> dict[str, Any]: + if get_environment_proxies is None: + return transport_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. + # Proxied connections are not given keepalive. + mounts = { + 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} + + +def create_http_client() -> httpx.Client: + """Construct the SDK's synchronous client with HTTPX defaults.""" + return httpx.Client(**_client_transport_options(httpx.HTTPTransport, {})) + + +def create_async_http_client( + *, + 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. + + 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, 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 cafa29dc..c6cf69c9 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 @@ -297,7 +300,7 @@ async def run_tasks( verify, cert = _resolve_tls_config() 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", operation_id, len(coroutines), concurrency_level, @@ -307,10 +310,11 @@ async def run_tasks( limits.max_keepalive_connections, limits.keepalive_expiry, _describe_tls_config(verify, cert), + resolve_tcp_keepalive().describe(), ) - async with httpx.AsyncClient( - timeout=client_timeout, limits=limits, verify=verify, cert=cert + async with create_async_http_client( + 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 = [ @@ -905,7 +909,7 @@ def _before_request_unlocked( plan_limits = _resolve_pool_limits() plan_verify, plan_cert = _resolve_tls_config() 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", operation_id, Path(pdf_file_meta["filename"]).name, form_data.get("strategy"), @@ -928,6 +932,7 @@ def _before_request_unlocked( plan_limits.max_keepalive_connections, plan_limits.keepalive_expiry, _describe_tls_config(plan_verify, plan_cert), + 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 3671fa6e..8a7021fe 100644 --- a/src/unstructured_client/sdk.py +++ b/src/unstructured_client/sdk.py @@ -5,11 +5,13 @@ 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: