Skip to content

Render transport failures as one clean CLI line#46

Open
arseniy-pplx wants to merge 1 commit into
TangleML:masterfrom
arseniy-pplx:transfer/clean-transport-errors
Open

Render transport failures as one clean CLI line#46
arseniy-pplx wants to merge 1 commit into
TangleML:masterfrom
arseniy-pplx:transfer/clean-transport-errors

Conversation

@arseniy-pplx

@arseniy-pplx arseniy-pplx commented Jul 20, 2026

Copy link
Copy Markdown

Summary

Connection, timeout, TLS, proxy, and mid-response failures from the static requests client previously bubbled up as a full Python traceback. Besides being noisy, the raw exception text can embed proxy credentials (userinfo) or signed-URL query secrets, so it was also a disclosure risk. This change turns those no-response transport failures into a single, credential-safe line.

  • api_transport.py adds three reusable helpers: sanitize_destination() reduces a URL to scheme://host[:port]/path (dropping userinfo, query, and fragment; IPv6 literals re-bracketed so [2001:db8::1]:8443 stays a valid origin); transport_error_reason() maps a requests transport exception to a short phrase (e.g. TLS verification failed, connection timed out, connection closed mid-response), most-specific type first; format_transport_error() composes Could not reach Tangle API (<METHOD> <destination>): <reason> without echoing raw exception text.
  • client.py performs the conversion at a single public boundary, _send_request(), which wraps _make_request(). A no-response RequestException that survives every layer becomes a TangleApiTransportError (a requests.exceptions.RequestException subclass) carrying the formatted line, with the original chained as __cause__. _make_request() and the rate-limit/redirect layers beneath it re-raise the original requests exception subtypes untouched, so a lower retry layer can still classify connect/timeout failures. Errors that carry an HTTP response are re-raised unchanged, so status and retry handling are untouched.
  • component_inspector.py routes its direct request through _send_request() for the same clean-error treatment.
  • cli.py prints the one-line message to stderr and exits nonzero at a run() boundary. It only intercepts transport failures (no response); HTTP status errors and ordinary programmer errors propagate as before, and it also formats a raw transport exception that bypassed the client.

Malformed authorities stay inside the boundary

urlsplit() parses hostname/port lazily, so a non-numeric port (host:bad) or an unterminated IPv6 literal ([::1) raises ValueError only on attribute access — outside the original guard. sanitize_destination() now reads host and port inside its guarded block and returns a safe fallback, and client._url() re-raises a construction-time ValueError as requests.exceptions.InvalidURL (no HTTP response) so a malformed base URL routes through the same _send_request() boundary and surfaces as a credential-safe TangleApiTransportError instead of an unhandled ValueError.

Composition with the root TLS flags (meta dispatch)

The CLI entrypoint dispatches through the Cyclopts meta app (build_app().meta(...)), with a passthrough launcher registered on app.meta.default. This keeps the clean-error boundary while allowing a sibling branch that installs global root options on app.meta.default (TLS flags that must apply before dynamic schema discovery) to compose here without this module importing that feature: a merge keeps the richer launcher and this runner keeps routing through it.

Context

Redaction, method, and sanitized destination are applied consistently across every static-client command (pipeline-runs, artifacts, components, pipelines, published-components, secrets) because they all route through the same _send_request() boundary.

Testing

  • uv run pytest tests/test_transport_errors.py tests/test_packaging.py covering URL sanitization (userinfo, query including signed-URL params, fragment), IPv6 re-bracketing, malformed-authority fallback for non-numeric ports and unterminated IPv6 literals (both sanitize_destination() and the client wrapping them as a clean domain error without leaking embedded credentials), reason classification (SSL/proxy/connect-timeout/read-timeout/timeout/chunked/connection/generic), the formatter never echoing raw secret-bearing text, the inner redirect strategy and _make_request re-raising the raw requests subtype unchanged, _send_request converting to TangleApiTransportError with __cause__ preserved, HTTP-response errors re-raised unchanged (programmer errors not swallowed), a genuinely refused localhost port yielding a clean domain error, the runner dispatching through the meta app, a combined test that a global root option parsed by the meta launcher is applied before the command runs while a static-client transport failure still renders as one clean stderr line, and the CLI rendering one stderr line with a nonzero exit
  • Full suite green on Python 3.12 and 3.13 apart from one pre-existing failure (test_api_cli.py::test_official_static_command_without_schema_fails_with_actionable_error) that reproduces identically on the base commit and is unrelated to this change
  • uvx ruff check on the touched files
  • uv lock --check; wheel build
  • git diff --check

# ``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:

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 guard parts.port as part of URL parsing.

urlsplit() accepts http://example.com:bad, but accessing .port raises ValueError outside the current try. A concrete call to TangleApiClient("http://example.com:bad").pipeline_runs_get("run-1") therefore escapes as ValueError: Port could not be cast..., with no chained TangleApiTransportError, defeating this PR’s clean-error boundary.

Could hostname/port extraction stay inside the guarded block and return a safe fallback when parsing either fails? Malformed textual ports and malformed IPv6 authorities would be useful regression cases.


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)

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.
@arseniy-pplx
arseniy-pplx force-pushed the transfer/clean-transport-errors branch from e2f7514 to 74dafa9 Compare July 24, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants