Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/tangle-cli/src/tangle_cli/api_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Any

import httpx
import requests

DEFAULT_API_URL = "http://localhost:8000"
DEFAULT_TIMEOUT_SECONDS = 30.0
Expand Down Expand Up @@ -40,6 +41,85 @@ 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)
except ValueError:
return None
if not parts.scheme and not parts.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
host = parts.hostname or ""
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 parts.port is not None:
host = f"{host}:{parts.port}"
return urllib.parse.urlunsplit((parts.scheme, host, parts.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():
Expand Down
39 changes: 38 additions & 1 deletion packages/tangle-cli/src/tangle_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from __future__ import annotations

import sys

import requests
from cyclopts import App

from . import (
Expand All @@ -11,6 +16,7 @@
quickstart,
secrets_cli,
)
from .api_transport import format_transport_error


def version() -> None:
Expand Down Expand Up @@ -49,8 +55,39 @@ def build_app() -> App:
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()(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__":
Expand Down
47 changes: 45 additions & 2 deletions packages/tangle-cli/src/tangle_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
_normalize_base_url,
_request_headers,
default_base_url,
format_transport_error,
log_http_exchange,
tangle_verbose_enabled,
)
Expand All @@ -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.

Expand Down Expand Up @@ -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._url(path)),
request=getattr(exc, "request", None),
) from exc

def _request_with_rate_limit_retries(
self,
method: str,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -358,7 +401,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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,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",
)

Expand Down
Loading