From 74dafa942371682df3d4b9653457b2dff9e1d62c Mon Sep 17 00:00:00 2001 From: Arseniy Dugin Date: Mon, 20 Jul 2026 15:46:10 +0000 Subject: [PATCH] Render transport failures as one clean CLI line Connection, timeout, TLS, proxy, and mid-response failures from the static requests client previously surfaced as a full traceback that could echo proxy credentials or signed-URL query secrets. Classify these no-response transport errors into a short reason phrase and format a single, credential-safe line that includes the method and a sanitized destination (userinfo, query, and fragment stripped). Native requests exceptions stay raw through the retry and redirect layers so those layers can still classify and retry connect/timeout failures; conversion happens only at the public _send_request boundary, which wraps a surviving no-response RequestException as a TangleApiTransportError (a requests.exceptions.RequestException subclass) with the original chained as __cause__. The CLI prints one line to stderr and exits nonzero. Errors that carry an HTTP response and programmer errors are never intercepted, so status and retry handling stay intact. Malformed authorities are kept inside the boundary. urlsplit() parses hostname and port lazily, so a non-numeric port (host:bad) or an unterminated IPv6 literal ([::1) raises ValueError only on access: sanitize_destination now reads both inside its guard and returns a safe fallback, and _url() re-raises a construction-time ValueError as requests InvalidURL so it routes through the same clean boundary instead of escaping as a bare ValueError. The CLI entrypoint dispatches through the Cyclopts meta app (build_app().meta(...)) with a passthrough launcher on app.meta.default. This keeps the clean-error boundary while letting a sibling branch that registers global root options on app.meta.default (TLS flags applied before dynamic schema discovery) compose here: a merge keeps the richer launcher and this runner keeps routing through it. A combined test pins that a root option parsed by the launcher is applied before the command runs while a static-client transport failure still renders as one clean line. --- .../src/tangle_cli/api_transport.py | 88 ++++ packages/tangle-cli/src/tangle_cli/cli.py | 56 +- packages/tangle-cli/src/tangle_cli/client.py | 71 ++- .../src/tangle_cli/component_inspector.py | 2 +- tests/test_packaging.py | 14 +- tests/test_transport_errors.py | 479 ++++++++++++++++++ 6 files changed, 702 insertions(+), 8 deletions(-) create mode 100644 tests/test_transport_errors.py diff --git a/packages/tangle-cli/src/tangle_cli/api_transport.py b/packages/tangle-cli/src/tangle_cli/api_transport.py index b128070..898f70c 100644 --- a/packages/tangle-cli/src/tangle_cli/api_transport.py +++ b/packages/tangle-cli/src/tangle_cli/api_transport.py @@ -11,6 +11,7 @@ from typing import Any import httpx +import requests DEFAULT_API_URL = "http://localhost:8000" DEFAULT_TIMEOUT_SECONDS = 30.0 @@ -40,6 +41,93 @@ def tangle_verbose_enabled() -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def sanitize_destination(url: str | None) -> str | None: + """Return ``scheme://host[:port]/path`` with userinfo, query, and fragment dropped. + + Userinfo can embed proxy credentials and query strings can carry signed-URL + tokens, so only the safe origin and path are kept for user-facing errors. + """ + + if not url: + return None + try: + parts = urllib.parse.urlsplit(url) + # ``hostname`` and ``port`` are parsed lazily and raise ``ValueError`` for a + # malformed IPv6 authority or a non-numeric port (e.g. ``host:bad``), so both + # accesses must stay guarded; otherwise the exception escapes past the + # transport-error boundary that calls this helper. + scheme = parts.scheme + netloc = parts.netloc + path = parts.path + host = parts.hostname or "" + port = parts.port + except ValueError: + return None + if not scheme and not netloc: + # A bare reference with no origin may itself be a signed URL; keep only + # the path portion and discard any query/fragment. + return url.split("?", 1)[0].split("#", 1)[0] or None + if ":" in host: + # ``hostname`` strips the brackets around an IPv6 literal; restore them so + # the origin stays a valid URL and any port remains unambiguous. + host = f"[{host}]" + if port is not None: + host = f"{host}:{port}" + return urllib.parse.urlunsplit((scheme, host, path, "", "")) or None + + +def transport_error_reason(exc: BaseException) -> str: + """Classify a requests transport failure into a short, safe reason phrase. + + The mapping references ``requests.exceptions`` lazily so importing this module + stays cheap and does not depend on the full requests package being present. + """ + + exceptions = requests.exceptions + # Ordered most-specific first: several transport errors share a base + # (SSLError/ProxyError subclass ConnectionError; ConnectTimeout subclasses both + # ConnectionError and Timeout), so the first match wins. + reasons: tuple[tuple[type[BaseException], str], ...] = ( + (exceptions.SSLError, "TLS verification failed"), + (exceptions.ProxyError, "proxy connection failed"), + (exceptions.ConnectTimeout, "connection timed out"), + (exceptions.ReadTimeout, "read timed out"), + (exceptions.Timeout, "request timed out"), + (exceptions.ChunkedEncodingError, "connection closed mid-response"), + (exceptions.ConnectionError, "could not connect"), + ) + for exc_type, reason in reasons: + if isinstance(exc, exc_type): + return reason + return "request failed" + + +def format_transport_error( + exc: BaseException, + *, + method: str | None = None, + url: str | None = None, +) -> str: + """Build a one-line, credential-safe message for a transport failure. + + The raw exception text is never echoed because it can contain proxy URLs or + request paths with signed-URL query secrets; the reason is derived from the + exception type and the destination is reduced to a sanitized origin+path. + """ + + request = getattr(exc, "request", None) + if url is None and request is not None: + url = getattr(request, "url", None) + if method is None and request is not None: + method = getattr(request, "method", None) + reason = transport_error_reason(exc) + destination = sanitize_destination(url) + if destination: + prefix = f"{method} " if method else "" + return f"Could not reach Tangle API ({prefix}{destination}): {reason}" + return f"Could not reach Tangle API: {reason}" + + def _redact_headers(headers: dict[str, Any] | None) -> dict[str, Any]: redacted: dict[str, Any] = {} for name, value in (headers or {}).items(): diff --git a/packages/tangle-cli/src/tangle_cli/cli.py b/packages/tangle-cli/src/tangle_cli/cli.py index e9c1fd4..5e1df21 100644 --- a/packages/tangle-cli/src/tangle_cli/cli.py +++ b/packages/tangle-cli/src/tangle_cli/cli.py @@ -1,4 +1,10 @@ -from cyclopts import App +from __future__ import annotations + +import sys +from typing import Annotated + +import requests +from cyclopts import App, Parameter from . import ( __version__, @@ -11,6 +17,7 @@ quickstart, secrets_cli, ) +from .api_transport import format_transport_error def version() -> None: @@ -46,11 +53,56 @@ def build_app() -> App: app.command(quickstart.app) app.command(api_cli.build_app()) app.command(build_sdk_app()) + + @app.meta.default + def launcher(*tokens: Annotated[str, Parameter(allow_leading_hyphen=True)]) -> None: + """Dispatch the requested command through the meta app. + + The runner enters the CLI via ``app.meta`` so that a sibling branch which + registers global root options on ``app.meta.default`` (e.g. TLS flags that + must apply before dynamic schema discovery) composes here without this + module importing that feature: a merge keeps the richer launcher while + this runner keeps routing through it. + """ + + app(tokens) + return app +def run(tokens: list[str] | None = None) -> int: + """Dispatch the CLI, rendering transport failures as one clean stderr line. + + The static requests client raises transport failures with no HTTP response; + they are printed without a traceback and mapped to a nonzero exit. HTTP status + errors carry a response and stay the command layer's responsibility, so they + are re-raised unchanged. + """ + + try: + build_app().meta(tokens) + except requests.exceptions.RequestException as exc: + if getattr(exc, "response", None) is not None: + raise + print(_transport_error_line(exc), file=sys.stderr) + return 1 + return 0 + + +def _transport_error_line(exc: requests.exceptions.RequestException) -> str: + # The static client already formats its failures into a clean line, so only raw + # requests exceptions that bypassed it need formatting here. The client module is + # only inspected if it is already imported (it must be, to have raised the domain + # error), so local-only commands never load the generated API bindings. + client_module = sys.modules.get(f"{__package__}.client") + domain_error = getattr(client_module, "TangleApiTransportError", None) + if domain_error is not None and isinstance(exc, domain_error): + return str(exc) + return format_transport_error(exc) + + def main() -> None: - build_app()() + raise SystemExit(run()) if __name__ == "__main__": diff --git a/packages/tangle-cli/src/tangle_cli/client.py b/packages/tangle-cli/src/tangle_cli/client.py index 060c960..a98d792 100644 --- a/packages/tangle-cli/src/tangle_cli/client.py +++ b/packages/tangle-cli/src/tangle_cli/client.py @@ -23,6 +23,7 @@ _normalize_base_url, _request_headers, default_base_url, + format_transport_error, log_http_exchange, tangle_verbose_enabled, ) @@ -40,6 +41,16 @@ ) +class TangleApiTransportError(requests.exceptions.RequestException): + """A connection/timeout/TLS/proxy/stream failure carrying no HTTP response. + + The message is a single, credential-safe line. Subclassing + ``requests.exceptions.RequestException`` keeps callers that already handle + requests errors working unchanged, while the originating exception is chained + as ``__cause__`` so programmatic hooks can still inspect the low-level cause. + """ + + class TangleApiClient(GeneratedTangleApiOperations): """Single public API wrapper for Tangle backends. @@ -144,6 +155,35 @@ def _make_request( ) return response + def _send_request( + self, + method: str, + path: str, + params: Mapping[str, Any] | None = None, + json_data: Any = None, + **kwargs: Any, + ) -> requests.Response: + """Issue a request, converting an unhandled transport failure to a clean error. + + ``_make_request`` and every retry/redirect layer beneath it re-raise the + original ``requests`` exception subtypes, so callers that manage their own + retries -- and the transient-retry layer -- can classify and retry them. + This public boundary is where a failure that survived all of those layers + becomes a credential-safe :class:`TangleApiTransportError`. HTTP status + errors carry a response and stay the caller's responsibility, so they + propagate unchanged. + """ + + try: + return self._make_request(method, path, params=params, json_data=json_data, **kwargs) + except requests.exceptions.RequestException as exc: + if getattr(exc, "response", None) is not None: + raise + raise TangleApiTransportError( + format_transport_error(exc, method=method.upper(), url=self._safe_error_url(path)), + request=getattr(exc, "request", None), + ) from exc + def _request_with_rate_limit_retries( self, method: str, @@ -224,6 +264,9 @@ def _request_with_same_origin_redirects( for _ in range(self._MAX_REDIRECTS + 1): request_headers = self._headers(extra_headers) + # Transport failures raised here propagate untouched so the enclosing + # retry/rate-limit layers can classify them; conversion to a clean + # TangleApiTransportError happens once, at the _send_request boundary. response = self.session.request( current_method, current_url, @@ -296,7 +339,7 @@ def _request_json( response_model: Any = None, ) -> Any: formatted_path = self._format_path(path, path_params) - response = self._make_request(method, formatted_path, params=params, json_data=json_data) + response = self._send_request(method, formatted_path, params=params, json_data=json_data) response.raise_for_status() data = self._decode_response(response) if response_model is not None and isinstance(data, dict): @@ -321,7 +364,29 @@ def _headers(self, extra_headers: Mapping[str, str] | None = None) -> dict[str, ) def _url(self, path: str) -> str: - return _join_operation_url(self.base_url, path) + """Build the absolute request URL, mapping a malformed base to a transport error. + + ``_join_operation_url`` accesses the authority while joining, so a malformed + base URL (an unterminated IPv6 literal such as ``http://[::1``) raises a bare + ``ValueError`` during URL construction, before any request is issued. + Re-raising it as ``requests.exceptions.InvalidURL`` -- which carries no HTTP + response -- routes it through the same ``_send_request`` boundary as other + transport failures, so it surfaces as a credential-safe + :class:`TangleApiTransportError` instead of an unhandled ``ValueError``. + """ + + try: + return _join_operation_url(self.base_url, path) + except ValueError as exc: + raise requests.exceptions.InvalidURL(str(exc)) from exc + + def _safe_error_url(self, path: str) -> str | None: + """Best-effort destination for an error message; a malformed base yields none.""" + + try: + return self._url(path) + except requests.exceptions.RequestException: + return None @staticmethod def _format_path(path: str, path_params: Mapping[str, Any] | None = None) -> str: @@ -358,7 +423,7 @@ def get_execution_details(self, execution_id: str) -> GetExecutionInfoResponse: return details def stream_execution_container_log(self, execution_id: str) -> requests.Response: - response = self._make_request( + response = self._send_request( "GET", self._format_path( "/api/executions/{id}/stream_container_log", diff --git a/packages/tangle-cli/src/tangle_cli/component_inspector.py b/packages/tangle-cli/src/tangle_cli/component_inspector.py index 92f16c5..b2a9328 100644 --- a/packages/tangle-cli/src/tangle_cli/component_inspector.py +++ b/packages/tangle-cli/src/tangle_cli/component_inspector.py @@ -33,7 +33,7 @@ def _request_path(client: TangleApiClient, path: str) -> Any: if callable(custom_request_path): response = custom_request_path(path) else: - response = client._make_request("GET", path) + response = client._send_request("GET", path) response.raise_for_status() return response diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 4092937..71b85c9 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -40,7 +40,8 @@ def _write_import_stubs(path: Path) -> None: _write_runtime_stubs(path) (path / "cyclopts.py").write_text( "class App:\n" - " def __init__(self, *args, **kwargs): pass\n" + " def __init__(self, *args, **kwargs):\n" + " self.meta = self\n" " def command(self, obj=None, **kwargs):\n" " if obj is not None:\n" " return obj\n" @@ -77,7 +78,16 @@ def _write_runtime_stubs(path: Path) -> None: " raise RuntimeError('request stub should not be called')\n" "\n" "class Response:\n" - " pass\n", + " pass\n" + "\n" + "class RequestException(Exception):\n" + " def __init__(self, *args, **kwargs):\n" + " self.response = kwargs.pop('response', None)\n" + " self.request = kwargs.pop('request', None)\n" + " super().__init__(*args)\n" + "\n" + "class exceptions:\n" + " RequestException = RequestException\n", encoding="utf-8", ) diff --git a/tests/test_transport_errors.py b/tests/test_transport_errors.py new file mode 100644 index 0000000..f1f620d --- /dev/null +++ b/tests/test_transport_errors.py @@ -0,0 +1,479 @@ +"""Transport-failure handling for the static requests client and CLI boundary.""" + +from __future__ import annotations + +import socket +from typing import Annotated, Any + +import pytest +import requests +from cyclopts import App, Parameter + +from tangle_cli import cli +from tangle_cli.api_transport import ( + format_transport_error, + sanitize_destination, + transport_error_reason, +) +from tangle_cli.client import TangleApiClient, TangleApiTransportError + + +class RaisingSession: + """A requests-like session whose every request raises a transport error.""" + + def __init__(self, exc: BaseException) -> None: + self.exc = exc + self.calls = 0 + + def request(self, *args: Any, **kwargs: Any) -> requests.Response: + self.calls += 1 + raise self.exc + + +def _client(exc: BaseException) -> TangleApiClient: + return TangleApiClient("https://api.test", session=RaisingSession(exc)) + + +def _prepared(method: str | None = "GET", url: str = "https://api.test/api/x") -> requests.PreparedRequest: + request = requests.PreparedRequest() + request.method = method + request.url = url + return request + + +# --- URL sanitization ------------------------------------------------------- + + +def test_sanitize_destination_strips_userinfo_and_query() -> None: + sanitized = sanitize_destination( + "https://user:pass@proxy.internal:8080/api/x?token=SEKRET&sig=abc#frag" + ) + assert sanitized == "https://proxy.internal:8080/api/x" + assert "pass" not in sanitized + assert "SEKRET" not in sanitized + + +def test_sanitize_destination_drops_signed_url_query() -> None: + sanitized = sanitize_destination( + "https://bucket.example.com/artifact?X-Amz-Signature=deadbeef&X-Amz-Credential=k" + ) + assert sanitized == "https://bucket.example.com/artifact" + + +def test_sanitize_destination_bare_path_drops_query() -> None: + assert sanitize_destination("/api/pipeline_runs?token=abc") == "/api/pipeline_runs" + + +def test_sanitize_destination_rebrackets_ipv6_host() -> None: + # ``hostname`` drops the brackets; without restoring them the origin renders as + # an ambiguous ``::1:8080`` and stops being a valid URL. + assert ( + sanitize_destination("https://user:pw@[::1]:8080/api/x?token=SEKRET") + == "https://[::1]:8080/api/x" + ) + assert sanitize_destination("http://[2001:db8::1]/api/y") == "http://[2001:db8::1]/api/y" + + +@pytest.mark.parametrize("value", [None, ""]) +def test_sanitize_destination_handles_empty(value: str | None) -> None: + assert sanitize_destination(value) is None + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com:bad/api/x?token=SEKRET", + "http://user:pw@example.com:99999/api/x", + "http://[::1/api/x?token=SEKRET", + "http://user:pw@[::1/api/y", + ], +) +def test_sanitize_destination_falls_back_on_malformed_authority(url: str) -> None: + # ``hostname``/``port`` are parsed lazily and raise ``ValueError`` for a + # non-numeric port or a malformed IPv6 authority; the helper must swallow that + # and return a safe fallback rather than letting it escape past the boundary. + result = sanitize_destination(url) + assert result is None + assert "SEKRET" not in (result or "") + assert "pw" not in (result or "") + + +# --- reason classification -------------------------------------------------- + + +@pytest.mark.parametrize( + ("exc", "reason"), + [ + (requests.exceptions.SSLError("x"), "TLS verification failed"), + (requests.exceptions.ProxyError("x"), "proxy connection failed"), + (requests.exceptions.ConnectTimeout("x"), "connection timed out"), + (requests.exceptions.ReadTimeout("x"), "read timed out"), + (requests.exceptions.Timeout("x"), "request timed out"), + (requests.exceptions.ChunkedEncodingError("x"), "connection closed mid-response"), + (requests.exceptions.ConnectionError("x"), "could not connect"), + (requests.exceptions.RequestException("x"), "request failed"), + ], +) +def test_transport_error_reason(exc: BaseException, reason: str) -> None: + assert transport_error_reason(exc) == reason + + +def test_format_transport_error_uses_request_metadata() -> None: + exc = requests.exceptions.ConnectionError("boom", request=_prepared("POST")) + assert format_transport_error(exc) == ( + "Could not reach Tangle API (POST https://api.test/api/x): could not connect" + ) + + +def test_format_transport_error_without_destination() -> None: + assert ( + format_transport_error(requests.exceptions.Timeout("slow")) + == "Could not reach Tangle API: request timed out" + ) + + +def test_format_transport_error_never_echoes_raw_text() -> None: + # A raw requests message can embed the request path with query secrets. + exc = requests.exceptions.ConnectionError( + "HTTPConnectionPool: Max retries exceeded with url: /x?token=SEKRET", + request=_prepared(url="https://api.test/x?token=SEKRET"), + ) + message = format_transport_error(exc) + assert "SEKRET" not in message + assert "token" not in message + + +# --- client boundary -------------------------------------------------------- + + +def test_client_wraps_connection_error_as_domain_error() -> None: + cause = requests.exceptions.ConnectionError("refused") + client = _client(cause) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + message = str(excinfo.value) + assert message == ( + "Could not reach Tangle API (GET https://api.test/api/pipeline_runs/run-1): " + "could not connect" + ) + assert "Traceback" not in message + # Cause preserved for programmatic hooks. + assert excinfo.value.__cause__ is cause + + +def test_client_wraps_timeout() -> None: + client = _client(requests.exceptions.ReadTimeout("read timed out")) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + assert str(excinfo.value).endswith("read timed out") + + +def test_client_wraps_ssl_error() -> None: + client = _client(requests.exceptions.SSLError("certificate verify failed")) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + assert "TLS verification failed" in str(excinfo.value) + + +def test_client_wraps_chunked_encoding_error() -> None: + client = _client(requests.exceptions.ChunkedEncodingError("peer closed")) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + assert "connection closed mid-response" in str(excinfo.value) + + +def test_client_proxy_error_does_not_leak_credentials() -> None: + # Proxy credentials can appear in the destination URL; they must not surface. + request = _prepared(url="https://user:s3cr3t@api.test/api/pipeline_runs/run-1") + cause = requests.exceptions.ProxyError("Cannot connect to proxy", request=request) + client = _client(cause) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + message = str(excinfo.value) + assert "s3cr3t" not in message + assert "user:" not in message + assert "proxy connection failed" in message + + +def test_client_does_not_intercept_errors_carrying_a_response() -> None: + response = requests.Response() + response.status_code = 500 + cause = requests.exceptions.RequestException("server error", response=response) + client = _client(cause) + with pytest.raises(requests.exceptions.RequestException) as excinfo: + client.pipeline_runs_get("run-1") + # Re-raised unchanged, not wrapped, so status handling stays intact. + assert excinfo.value is cause + assert not isinstance(excinfo.value, TangleApiTransportError) + + +def test_client_does_not_swallow_programmer_errors() -> None: + client = _client(ValueError("bad code")) + with pytest.raises(ValueError): + client.pipeline_runs_get("run-1") + + +def test_client_real_refused_port() -> None: + # No mocking: a genuinely closed localhost port must yield a clean domain error. + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + client = TangleApiClient(f"http://127.0.0.1:{port}", timeout=2.0) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + message = str(excinfo.value) + assert message.startswith( + f"Could not reach Tangle API (GET http://127.0.0.1:{port}/api/pipeline_runs/run-1)" + ) + assert "Traceback" not in message + + +@pytest.mark.parametrize( + "base_url", + [ + "http://example.com:bad", + "http://user:s3cr3t@example.com:bad", + "http://[::1", + "http://user:s3cr3t@[::1", + ], +) +def test_client_wraps_malformed_authority_as_domain_error(base_url: str) -> None: + # A non-numeric port surfaces from the request layer while an unterminated IPv6 + # authority raises during URL construction; both must chain through the clean + # domain error rather than escaping as a bare ``ValueError``, and neither may + # leak embedded credentials. + client = TangleApiClient(base_url) + with pytest.raises(TangleApiTransportError) as excinfo: + client.pipeline_runs_get("run-1") + message = str(excinfo.value) + assert message.startswith("Could not reach Tangle API") + assert "s3cr3t" not in message + assert "Traceback" not in message + + +# --- wrapping boundary ------------------------------------------------------- +# +# ``_make_request`` and every retry/rate-limit/redirect layer beneath it re-raise +# the original ``requests`` exception subtypes; conversion to a clean +# TangleApiTransportError happens only at the ``_send_request`` public boundary. +# Keeping the low-level method raw is what lets a transient-retry layer inserted +# below ``_make_request`` classify and retry connect/timeout failures. These tests +# pin that split so it cannot silently regress back into an inner layer. + + +def test_inner_request_strategy_reraises_raw_transport_exception() -> None: + # The redirect layer that issues session.request must let the original requests + # exception through unconverted, so an enclosing retry layer can classify it. + cause = requests.exceptions.ConnectionError("refused") + client = _client(cause) + with pytest.raises(requests.exceptions.ConnectionError) as excinfo: + client._request_with_same_origin_redirects( + "GET", + "https://api.test/api/x", + params=None, + json_data=None, + extra_headers=None, + timeout=1.0, + request_kwargs={}, + ) + assert excinfo.value is cause + assert not isinstance(excinfo.value, TangleApiTransportError) + + +def test_make_request_surfaces_raw_transport_exception() -> None: + # _make_request stays raw so a transient-retry layer wrapping it sees the + # original subtype; only _send_request converts. This guards the composition + # with a later retry change that retries connect/timeout failures. + cause = requests.exceptions.ConnectionError("refused") + client = _client(cause) + with pytest.raises(requests.exceptions.ConnectionError) as excinfo: + client._make_request("GET", "/api/x") + assert excinfo.value is cause + assert not isinstance(excinfo.value, TangleApiTransportError) + + +def test_send_request_converts_at_public_boundary() -> None: + cause = requests.exceptions.ConnectionError("refused") + client = _client(cause) + with pytest.raises(TangleApiTransportError) as excinfo: + client._send_request("GET", "/api/pipeline_runs/run-1") + assert str(excinfo.value) == ( + "Could not reach Tangle API (GET https://api.test/api/pipeline_runs/run-1): " + "could not connect" + ) + assert excinfo.value.__cause__ is cause + assert "Traceback" not in str(excinfo.value) + + +def test_send_request_preserves_ssl_reason() -> None: + cause = requests.exceptions.SSLError("certificate verify failed") + client = _client(cause) + with pytest.raises(TangleApiTransportError) as excinfo: + client._send_request("GET", "/api/x") + assert "TLS verification failed" in str(excinfo.value) + assert excinfo.value.__cause__ is cause + + +def test_send_request_propagates_http_error_with_response_unchanged() -> None: + # Status errors carry a response and stay the caller's responsibility; the + # boundary must not swallow them into a transport error. + response = requests.Response() + response.status_code = 502 + cause = requests.exceptions.HTTPError("bad gateway", response=response) + client = _client(cause) + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + client._send_request("GET", "/api/x") + assert excinfo.value is cause + assert not isinstance(excinfo.value, TangleApiTransportError) + + +# --- CLI boundary ----------------------------------------------------------- + + +def _app_raising(exc: BaseException) -> App: + app = App(name="probe") + + @app.command(name="call") + def _call() -> None: + raise exc + + # ``run`` enters through ``app.meta``; a passthrough launcher mirrors the real + # root app so the fake dispatches identically. A sibling branch may enrich this + # launcher with global options, but the runner keeps routing through it. + @app.meta.default + def _launcher(*tokens: Annotated[str, Parameter(allow_leading_hyphen=True)]) -> None: + app(tokens) + + return app + + +def test_run_renders_domain_error_one_line(monkeypatch, capsys) -> None: + exc = TangleApiTransportError( + "Could not reach Tangle API (GET https://api.test/api/x): could not connect", + request=_prepared(), + ) + monkeypatch.setattr(cli, "build_app", lambda: _app_raising(exc)) + + exit_code = cli.run(["call"]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.err.strip() == ( + "Could not reach Tangle API (GET https://api.test/api/x): could not connect" + ) + assert "Traceback" not in captured.err + + +def test_run_formats_raw_requests_exception_safety_net(monkeypatch, capsys) -> None: + # A raw requests error that bypassed the client is formatted (and redacted) here. + exc = requests.exceptions.ConnectionError( + "boom", request=_prepared(url="https://api.test/x?token=SEKRET") + ) + monkeypatch.setattr(cli, "build_app", lambda: _app_raising(exc)) + + exit_code = cli.run(["call"]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.err.strip() == ( + "Could not reach Tangle API (GET https://api.test/x): could not connect" + ) + assert "SEKRET" not in captured.err + + +def test_run_reraises_errors_with_response(monkeypatch) -> None: + response = requests.Response() + response.status_code = 500 + exc = requests.exceptions.HTTPError("server error", response=response) + monkeypatch.setattr(cli, "build_app", lambda: _app_raising(exc)) + + with pytest.raises(requests.exceptions.HTTPError): + cli.run(["call"]) + + +def test_run_propagates_normal_exit(monkeypatch) -> None: + app = App(name="probe") + + @app.command(name="ok") + def _ok() -> None: + return None + + @app.meta.default + def _launcher(*tokens: Annotated[str, Parameter(allow_leading_hyphen=True)]) -> None: + app(tokens) + + monkeypatch.setattr(cli, "build_app", lambda: app) + + with pytest.raises(SystemExit) as excinfo: + cli.run(["ok"]) + assert excinfo.value.code == 0 + + +def test_run_dispatches_through_meta_app(monkeypatch) -> None: + # ``run`` must enter via ``app.meta`` so a sibling branch that installs global + # root options on ``app.meta.default`` (e.g. TLS flags applied before dynamic + # schema discovery) stays on the dispatch path. A launcher that is bypassed + # would silently drop those options, so pin that the launcher actually runs. + app = App(name="probe") + seen: list[str] = [] + + @app.command(name="ok") + def _ok() -> None: + return None + + @app.meta.default + def _launcher(*tokens: Annotated[str, Parameter(allow_leading_hyphen=True)]) -> None: + seen.append("launcher") + app(tokens) + + monkeypatch.setattr(cli, "build_app", lambda: app) + + with pytest.raises(SystemExit): + cli.run(["ok"]) + assert seen == ["launcher"] + + +def test_run_composes_root_option_with_transport_failure(monkeypatch, capsys) -> None: + # Combined integration: a global root option parsed by the meta launcher (the + # slot #43 uses for ``--ca-bundle`` / ``--no-verify-tls``) is applied before the + # command runs, and a static-client transport failure raised by that command + # still renders as one clean stderr line with a nonzero exit. This proves the + # two boundaries compose without this module importing the TLS feature. + applied: dict[str, Any] = {} + app = App(name="probe") + + @app.command(name="call") + def _call() -> None: + raise TangleApiTransportError( + "Could not reach Tangle API (GET https://api.test/api/x): could not connect", + request=_prepared(), + ) + + @app.meta.default + def _launcher( + *tokens: Annotated[str, Parameter(allow_leading_hyphen=True)], + strict: bool = False, + ) -> None: + applied["strict"] = strict + app(tokens) + + monkeypatch.setattr(cli, "build_app", lambda: app) + + exit_code = cli.run(["--strict", "call"]) + + captured = capsys.readouterr() + assert applied["strict"] is True + assert exit_code == 1 + assert captured.err.strip() == ( + "Could not reach Tangle API (GET https://api.test/api/x): could not connect" + ) + assert "Traceback" not in captured.err + + +def test_run_does_not_swallow_programmer_errors(monkeypatch) -> None: + monkeypatch.setattr(cli, "build_app", lambda: _app_raising(ValueError("bug"))) + + with pytest.raises(ValueError): + cli.run(["call"])