Skip to content

fix(errors): never mask a 4xx server message with the re-auth hint - #273

Merged
eddietejeda merged 1 commit into
mainfrom
fix/4xx-body-not-masked-by-reauth-hint
Aug 22, 2026
Merged

fix(errors): never mask a 4xx server message with the re-auth hint#273
eddietejeda merged 1 commit into
mainfrom
fix/4xx-body-not-masked-by-reauth-hint

Conversation

@eddietejeda

Copy link
Copy Markdown
Contributor

Problem

format_fail_message replaced the body of any 4xx response with error: API key is invalid. Run 'hotdata auth login' to re-authenticate. whenever the stored-profile credential probe reported Invalid. The probe validates a different credential than the request may have used (--api-key), so a stale stored session masked every real 4xx.

Observed in prod (2026-08-22): ~48 queries using a valid --api-key authenticated fine, reached RuntimeDB, and got correct 400 planning errors — Envoy access logs show response_code: 400, response_code_details: "via_upstream" — yet the CLI printed the auth message for every one. The engine was even returning did-you-mean suggestions (Invalid function 'st_quadkey'. Did you mean 'st_equals'?) that the CLI discarded. The misleading output sent debugging down an auth/rate-limit rabbit hole before logs proved the responses were fine on the wire.

Fix

  • Never discard the server's message. Hints (token-scope, re-auth) are appended after it, not substituted for it.
  • Re-auth hint only on 401/403 — the statuses where a broken credential is a plausible cause. A 400/404 body is the server explaining itself.
  • ApiError::print only probes the stored credential on 401/403, dropping a wasted GET /v1/workspaces round-trip (and misleading signal) from every other 4xx.

Behavior

Case Before After
400 planning error + stale stored session API key is invalid... Invalid function 'st_quadkey'. Did you mean 'st_equals'?
404 + stale stored session API key is invalid... server body / status line
401/403 + probe says Invalid hint only, body lost server body + appended hint
403 ACCESS_DENIED (+Invalid) hint only body + token-scope hint + re-auth hint
5xx / empty body / probe ConnectionError unchanged unchanged

Tests

  • New regression test for the masked-planning-error incident (400 + Invalid probe must surface the server message, not the hint).
  • The 404 test that encoded the old behavior is inverted, with a comment explaining why (a 404 means the request authenticated).
  • ACCESS_DENIED + Invalid test now also asserts the server message survives.
  • All 14 format_fail_message tests pass; full suite green; clippy clean.

Live validation (release build vs prod)

  • Unknown function: old binary prints API key is invalid; fixed binary prints the engine's planning error with suggestion.
  • Valid query: unaffected (ST_Distance_Sphere -> 111195.08..., 3ms).
  • Bogus --api-key: server's Invalid api key body plus the appended re-auth hint.

https://claude.ai/code/session_01AEq1HcG9b29mnRAFWesB7j

format_fail_message replaced the body of ANY 4xx response with "API key is
invalid. Run 'hotdata auth login'" whenever the stored-profile credential
probe reported Invalid. The probe checks a *different* credential than the
request may have used (--api-key), so a stale stored session masked every
real 4xx: in the 2026-08-22 prod session, 400 planning errors ("Invalid
function 'st_quadkey'." + did-you-mean suggestions) printed as an auth
failure for ~48 requests, sending debugging down an auth/rate-limit rabbit
hole. Envoy access logs confirmed the wire response was a correct 400
via_upstream; the masking was purely client-side.

Now:
- The server's message is never discarded; hints are appended after it.
- The re-auth hint fires only on 401/403, where a broken credential is a
  plausible cause. A 400/404 body is the server explaining itself.
- ApiError::print only runs the stored-profile probe on 401/403, dropping
  a wasted GET /v1/workspaces round-trip from every other 4xx.

Tests: regression test for the masked-planning-error incident; 404 test
inverted (it encoded the old behavior); ACCESS_DENIED+Invalid test now also
asserts the server message survives.

Verified live against prod: unknown-function queries print the engine's
planning error (old binary: "API key is invalid"), valid queries unaffected,
bogus key prints the server 401 body plus the appended hint.

Claude-Session: https://claude.ai/code/session_01AEq1HcG9b29mnRAFWesB7j
@eddietejeda
eddietejeda requested a review from a team as a code owner August 22, 2026 18:34
@eddietejeda
eddietejeda requested review from rohan-hotdata and removed request for a team August 22, 2026 18:34
Comment thread src/client/sdk.rs
match self {
ApiError::Status { status, body } => {
let auth_status = if status.is_client_error() {
// Probe the stored credential only when auth plausibly caused the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the print doc comment just above (lines 248-250) still describes the old behavior and now directly contradicts the code:

On a 4xx, re-probe the auth status so a masked 404/403 is upgraded into the "run hotdata auth login" hint; otherwise surface the server body.

A 404 is now explicitly never upgraded, and the body is never replaced. Since this PR is largely about making the behavior legible, worth updating that paragraph too. (not blocking)

Comment thread src/client/sdk.rs
Some(&AuthStatus::Invalid(403)),
);
assert!(msg.contains("API key is invalid"), "got: {msg}");
assert!(msg.contains('x'), "server message must be kept: {msg}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: contains('x') is a very weak guard for "the server message survived" — it passes on any output containing the letter x anywhere, and the two hint strings are one wording tweak away from containing one. A distinctive body message makes the assertion mean what the comment says:

Suggested change
assert!(msg.contains('x'), "server message must be kept: {msg}");
assert!(
msg.contains("server-explanation-here"),
"server message must be kept: {msg}"
);

(with the body at line 1061 changed to {"error":{"code":"ACCESS_DENIED","message":"server-explanation-here"}}). (not blocking)

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified the premise holds: credentials::check_status prefers the cached JWT session over the api_key fallback (src/client/credentials.rs:39-43), so a stale stored session yields Invalid(401) even when the request's own --api-key is fine — exactly the masking described. Narrowing the hint to 401/403 and appending rather than substituting is the right fix, and the probe elision in print follows from it.

Also checked that no other code, test, or doc depends on the old error: API key is invalid. Run ... string, so the wording change is self-contained.

Two non-blocking nits inline. Note CI (fmt/test/clippy) was still queued when this review ran, so I can't confirm the suite result.

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/client/sdk.rs 90.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@eddietejeda
eddietejeda merged commit 9101046 into main Aug 22, 2026
14 checks passed
@eddietejeda
eddietejeda deleted the fix/4xx-body-not-masked-by-reauth-hint branch August 22, 2026 18:49
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.

1 participant