Render transport failures as one clean CLI line#46
Conversation
| # ``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: |
There was a problem hiding this comment.
(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()) |
There was a problem hiding this comment.
(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.
e2f7514 to
74dafa9
Compare
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.pyadds three reusable helpers:sanitize_destination()reduces a URL toscheme://host[:port]/path(dropping userinfo, query, and fragment; IPv6 literals re-bracketed so[2001:db8::1]:8443stays a valid origin);transport_error_reason()maps arequeststransport exception to a short phrase (e.g.TLS verification failed,connection timed out,connection closed mid-response), most-specific type first;format_transport_error()composesCould not reach Tangle API (<METHOD> <destination>): <reason>without echoing raw exception text.client.pyperforms the conversion at a single public boundary,_send_request(), which wraps_make_request(). A no-responseRequestExceptionthat survives every layer becomes aTangleApiTransportError(arequests.exceptions.RequestExceptionsubclass) carrying the formatted line, with the original chained as__cause__._make_request()and the rate-limit/redirect layers beneath it re-raise the originalrequestsexception 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.pyroutes its direct request through_send_request()for the same clean-error treatment.cli.pyprints the one-line message to stderr and exits nonzero at arun()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()parseshostname/portlazily, so a non-numeric port (host:bad) or an unterminated IPv6 literal ([::1) raisesValueErroronly on attribute access — outside the original guard.sanitize_destination()now reads host and port inside its guarded block and returns a safe fallback, andclient._url()re-raises a construction-timeValueErrorasrequests.exceptions.InvalidURL(no HTTP response) so a malformed base URL routes through the same_send_request()boundary and surfaces as a credential-safeTangleApiTransportErrorinstead of an unhandledValueError.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 onapp.meta.default. This keeps the clean-error boundary while allowing a sibling branch that installs global root options onapp.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.pycovering URL sanitization (userinfo, query including signed-URL params, fragment), IPv6 re-bracketing, malformed-authority fallback for non-numeric ports and unterminated IPv6 literals (bothsanitize_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_requestre-raising the rawrequestssubtype unchanged,_send_requestconverting toTangleApiTransportErrorwith__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 exittest_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 changeuvx ruff checkon the touched filesuv lock --check; wheel buildgit diff --check