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
88 changes: 88 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,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():
Expand Down
56 changes: 54 additions & 2 deletions packages/tangle-cli/src/tangle_cli/cli.py
Original file line number Diff line number Diff line change
@@ -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__,
Expand All @@ -11,6 +17,7 @@
quickstart,
secrets_cli,
)
from .api_transport import format_transport_error


def version() -> None:
Expand Down Expand Up @@ -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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI-assisted)

Please compose this runner with #43’s Cyclopts meta dispatch before either PR merges.

PR #43 makes root TLS flags apply before dynamic schema discovery by entering build_app().meta(). This PR’s run() dispatches build_app()(tokens) instead, and both branches modify the same main() entrypoint; Git reports content conflicts in cli.py, client.py, and api_transport.py.

If this entrypoint wins as-is, #43’s --ca-bundle / --no-verify-tls path is bypassed. If #43’s entrypoint wins, this PR’s clean RequestException boundary is bypassed. Could the later branch rebase and make the guarded runner dispatch the meta app, with a combined integration test covering both a root TLS flag and a static-client transport failure?

Sibling discussion: #43 (comment)



if __name__ == "__main__":
Expand Down
71 changes: 68 additions & 3 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._safe_error_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 All @@ -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:
Expand Down Expand Up @@ -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",
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
14 changes: 12 additions & 2 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
)

Expand Down
Loading