fix(cli): explain a control-service transport failure instead of quoting rustls - #28
Merged
damonleelcx merged 1 commit intoAug 22, 2026
Conversation
…ing rustls
A user pointing `aikey login` at a plaintext control service on port 3000
got this:
Error: Login failed: login init failed: https://120.24.220.105:3000/...:
Connection Failed: tls connection init failed: received corrupt message
of type InvalidContentType
That text names no cause the user can act on, yet the cause was simple and
certain: the URL said https:// and the server answered in plain HTTP, so
rustls read an HTTP response where a TLS record should have been. The user
could not know that. The CLI could.
Not just login — all 12 control-plane calls in `platform_client` end in the
same `format!("<op> failed: {}", e)`, so every one of them rendered the same
unreadable chain.
`control_plane_error::explain` ENRICHES a failure it recognises and returns
the raw text UNCHANGED otherwise. It never replaces, summarises or swallows
the original: an unrecognised failure has to stay exactly as loud and as
complete as it is today. HTTP status errors return early — a business
outcome is not ours to interpret.
The recognised set is an ordered table, not a chain of heuristics. Each row
carries the kinds it may be concluded from as well as its text signatures,
because matching on text alone misdiagnoses: a corporate-proxy failure
(`ErrorKind::ProxyConnect`) also says "connection refused", and concluding
"nothing is listening on the control URL port" from it points the user at
the wrong machine entirely. errno is matched numerically (61/111/10061)
because `std::io::Error`'s Display is localised on Windows.
The classifier is pure — url, kind, full error text — because ureq's error
constructors are `pub(crate)` and a test cannot fabricate a `ureq::Error`.
The adapter is covered end-to-end against REAL local sockets instead: one
listener that speaks plain HTTP reproduces the reported failure exactly, and
a closed port reproduces the refusal. Without those, the table would be a
fence over an input shape nobody has confirmed ureq produces.
NOT COVERED, deliberately: the mirror case (http:// against a TLS-only
port). Measured against a live TLS endpoint — the server answers
`HTTP/1.1 400 Bad Request` in plaintext, so it surfaces as
`ureq::Error::Status(400)` with server-specific body text, never as a
transport failure. Diagnosing it belongs to the status layer and would have
to guess at nginx/cloudflare wording, so it is left alone rather than
guessed at.
Bugfix: workflow/CI/bugfix/20260821-cli-control-plane-transport-error-unactionable.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit dc495fd31edbc5689190be8a4101a934a1143044)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A user pointing
aikey loginat a plaintext control service on port 3000 got this:That names no cause the user can act on, yet the cause was simple and certain: the URL said
https://and the server answered in plain HTTP, so rustls read an HTTP response where a TLS record should have been. The user could not know that. The CLI could.Not just login. All 12 control-plane calls in
platform_clientend in the sameformat!("<op> failed: {}", e), so every one of them rendered the same unreadable chain —aikey list,sync,claim, key delivery, token refresh.After
Design
Enrich, never replace. An unrecognised failure is returned exactly as
ureqrendered it — as loud and as complete as today. HTTP status errors return early: a business outcome is not ours to interpret. Evidence quotes the original so the user can check the reasoning instead of trusting it.An ordered table, not a chain of heuristics. Each row carries the kinds it may be concluded from as well as its text signatures — because matching on text alone misdiagnoses. A corporate-proxy failure (
ErrorKind::ProxyConnect) also says "connection refused", and concluding "nothing is listening on the control URL port" from it sends the user to inspect the wrong machine. errno is matched numerically (61 / 111 / 10061) becausestd::io::Error's Display is localised on Windows.Covered: plaintext server, hostname mismatch, expired cert, untrusted cert, connection refused, timeout, DNS. The untrusted-cert advice deliberately never offers a verification bypass, and a fence asserts that.
Pure classifier + thin adapter.
ureq's error constructors arepub(crate), so a test cannot fabricate aureq::Error. Classification therefore takes three primitives (url, kind, full error text) and is directly unit-testable; the adapter is covered end-to-end against real local sockets — one listener speaking plain HTTP reproduces the reported failure exactly, another (closed port) reproduces the refusal. Without those, the table would be a fence over an input shape nobody has confirmedureqproduces.Not covered, deliberately
The mirror case (
http://against a TLS-only port). Measured against a live TLS endpoint: the server answersHTTP/1.1 400 Bad Requestin plaintext, so it surfaces asureq::Error::Status(400)with server-specific body text, never as a transport failure. Diagnosing it belongs to the status layer and would have to guess at nginx/cloudflare wording — left alone rather than guessed at.Verification
cargo test --libon this PR's base → 1313 passed, 0 failed;cargo clippy --libclean for these files.to_httpstop rewriting the scheme each turn the intended assertions red, and each mutation compiles (a mutation that fails to build proves nothing).config.json, so reproducing this does not disturb an existing setup.One test that is NOT a fence, labelled as such in the source
explain_never_touches_an_http_status_errorcannot go red: routingError::Statusthrough the classifier instead of returning early produces identical output, becauseTransportKind::Othermatches no table row. The early return is structural; the kinds column is what actually prevents misdiagnosis, and that one does go red. Recorded in the source so a future reader does not mistake the green for proof.Open question for the reviewer
These diagnostics carry no error code.
platform_clientisResult<_, String>end to end and has no codes today; wiring them intoerror_codes.rsmeans changing 12 signatures and every caller, and widening a stable public surface. Three options are laid out at the end of the bugfix doc — this PR takes the first (keep prose, no code) and does not treat that as settled.Bugfix:
workflow/CI/bugfix/20260821-cli-control-plane-transport-error-unactionable.md🤖 Generated with Claude Code