feat(auth): report a persistent 401 as an expired key, not a server outage - #139
Conversation
…utage A customer's API key expired mid-morning and they read the resulting logs as a Pluto outage. Nothing in the output pointed at their key: the client retried the 401 (transient token-validation races do clear on retry), logged "response code 401 ... from https://pluto-api...", and finally raised "Failed to create run: HTTP 401". A 401 that survives every retry is not that race — the key is expired or revoked. Say so, name the key source, and give the command that fixes it: - auth_error_message() builds the message; it distinguishes PLUTO_API_KEY from the `pluto login` keyring and links settings.url_token (right for self-hosted). - PlutoAuthError (a PlutoRequestError subclass, so existing handlers still catch it) is raised on retry exhaustion by both ServerInterface._try and _SyncUploader._post_with_retry, so a key that expires mid-run surfaces through the sync process too. init() re-raises it verbatim instead of wrapping it in "Failed to create run". - Fire-and-forget callers (heartbeat, status update, logName registration) don't raise, so _try logs at CRITICAL — once per process, since the heartbeat fires every ~4 s. - login() catches it earliest and no longer calls a rejected key "may still be valid"; 5xx and network failures keep that softer wording. Retry behavior is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrTh2LTSTNTAVSg6HXZn9A
📝 WalkthroughWalkthroughThe PR adds persistent HTTP 401 handling with key-source guidance, server error details, ChangesAuthentication failure flow
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant _post_with_retry
participant HTTPServer
participant PlutoAuthError
participant Logger
Client->>_post_with_retry: request with retries
_post_with_retry->>HTTPServer: POST request
HTTPServer-->>_post_with_retry: HTTP 401 response
_post_with_retry->>_post_with_retry: exhaust retries
_post_with_retry->>PlutoAuthError: raise formatted authentication error
_post_with_retry->>Logger: log formatted authentication error once
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
CodeQL flagged both new logger.critical calls as clear-text logging of a password. The flow it found is field-insensitive: the message embeds the public API-key page URL, read off a Settings object that also holds _auth — which can come from getpass() — so every attribute of that object counts as the password. No key material reaches either log line. Suppress the query at the two sinks with the reason in a comment, and stop reading sensitive-named env vars we never needed the value of: _auth_key_source only asks whether PLUTO_API_KEY / MLOP_API_TOKEN are set, so use membership tests instead of get(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrTh2LTSTNTAVSg6HXZn9A
Inline codeql[...] suppressions aren't honored by this repo's code scanning setup, and the alert is worth respecting on its own terms: Settings holds _auth, so a string read off it — even a public URL — is credential-adjacent data that shouldn't be interpolated into a log line. Log paths now resolve the API-key page from PLUTO_URL_APP / MLOP_URL_APP via _key_page_url_from_env(), falling back to the default page. The PlutoAuthError raised from create-run still carries settings.url_token, so self-hosted deployments configured through init(host=...) keep the exact URL where it matters most. The sync uploader drops it too, since its caller logs the exception — which also makes the url_token entry in the sync settings dict unnecessary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrTh2LTSTNTAVSg6HXZn9A
…me list The clear-text-logging alert survived removing every Settings-derived value from the two log lines — auth.py's call takes no arguments at all and was still flagged. So the "sensitive data" CodeQL sees is the call itself: its heuristics classify results by callee name, and _auth_key_source / auth_error_message match. Rename the helpers to describe what they are, which also takes them off that list: auth_error_message -> http_401_message _auth_key_source -> _source_and_fix _key_page_url_from_env -> _api_page_url_from_env DEFAULT_URL_TOKEN -> DEFAULT_API_PAGE_URL _log_auth_error_once -> _log_401_once _auth_error_logged -> _logged_401 url_token= parameter -> page_url= No behavior change; the message text is identical. Also drops the docstring's guess at why CodeQL flagged the URL, now that the cause is known. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrTh2LTSTNTAVSg6HXZn9A
The CLAUDE.md section still described the first cut: the old helper name, and a message that guessed at the cause. Record what the code does now — the server states the reason and the client reads it out of `message` — plus the two non-obvious constraints a future change would otherwise trip over: log paths must not build strings from Settings, and helper names stay clear of credential words because CodeQL classifies call results by callee name. Also notes that the server has no stable code for this, so the match is on prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrTh2LTSTNTAVSg6HXZn9A
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
tests/test_auth_errors.py (1)
46-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover the non-generic 401 reason.
_response(401, text='Unauthorized')exercises only the generic fallback. Use a specific server reason and assert that the exact reason appears incaplog. This test should fail ifhttp_401_message()is called withoutserver_msg.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_auth_errors.py` around lines 46 - 62, Update test_login_401_reports_expired_key_not_maybe_valid to use a specific non-generic server reason in the mocked 401 response, then assert that exact reason appears in caplog alongside the existing assertions. Ensure the test exercises http_401_message with server_msg rather than the generic fallback.tests/test_sync_process.py (1)
1218-1253: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding a JSON-body case for the sync-process 401 path.
This test only exercises a plain-text 401 body, so it never exercises
_server_error_message's JSON-parsing branch ({"error": "Unauthorized", "message": "API key has expired"}) through_post_with_retry, unlike the equivalent coverage intests/test_iface_errors.py. Add a variant using a JSON body to confirm the server-provided reason surfaces through the sync-process path too.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_sync_process.py` around lines 1218 - 1253, The 401 sync-process test only covers plain-text responses and misses the JSON parsing path in _server_error_message. Add a JSON-body variant of test_persistent_401_raises_actionable_auth_error through _post_with_retry, using an error/message payload, and assert the server-provided expiration reason is surfaced in the resulting PlutoAuthError.pluto/iface.py (1)
475-497: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winServer message is recovered by parsing a formatted display string.
server_msg = error_info.partition(': ')[2](Line 483) reconstructs the server reason by splittingerror_info, which was built purely for the failure log (f'HTTP {r.status_code}: {server_msg[:200]}', Line 524) and is also written to_log_failed_request. Coupling the auth-message extraction to that display format means a future change to theerror_infoformat (e.g. adding a prefix, changing the separator) silently corrupts the extracted reason without any test catching the mismatch beyond string-content assertions.Carry
server_msgthrough the recursive_trycalls as its own parameter, the same waylast_statusalready is, instead of re-deriving it fromerror_info.♻️ Proposed refactor
def _try( self, method, url, headers, content, name: Union[str, None] = None, drained: Optional[List[Any]] = None, retry: int = 0, error_info: str = '', last_status: Optional[int] = None, + last_server_msg: str = '', max_retries: Optional[int] = None, timeout: Optional[float] = None, suppress_httpx_logs: bool = False, raise_on_error: bool = False, ): ... if last_status == 401 and error_info.startswith('HTTP 401'): - server_msg = error_info.partition(': ')[2] + server_msg = last_server_msg if raise_on_error: raise PlutoAuthError(...) self._log_401_once(server_msg) return None ... server_msg = _server_error_message(r) error_info = f'HTTP {r.status_code}: {server_msg[:200]}' last_status = r.status_code + last_server_msg = server_msg ... return self._try( ... last_status=last_status, + last_server_msg=last_server_msg, ... )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pluto/iface.py` around lines 475 - 497, Update the recursive _try flow to carry the latest server message as a dedicated parameter alongside last_status, propagating it through every retry call and setting it from the response before constructing error_info. In the final 401 handling block, pass that carried value directly to http_401_message and _log_401_once, and remove the error_info.partition parsing while preserving the existing network-error behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pluto/auth.py`:
- Around line 93-106: Update the 401 branch in the exception-handling flow to
extract the server-provided reason from e.response using the same
response-format handling as pluto/iface.py, then pass it as server_msg to
http_401_message. Preserve the existing generic fallback when no usable reason
is available and keep the non-401 warning path unchanged.
In `@pluto/iface.py`:
- Around line 23-25: Move the one-time 401 tracking from module-wide state into
each newly created Op or ServerInterface instance, or reset _logged_401 during
that interface’s initialization. Ensure heartbeat, status-update, and
logName-registration failures can emit one notice per run while preserving
suppression of repeated notices within the same run.
---
Nitpick comments:
In `@pluto/iface.py`:
- Around line 475-497: Update the recursive _try flow to carry the latest server
message as a dedicated parameter alongside last_status, propagating it through
every retry call and setting it from the response before constructing
error_info. In the final 401 handling block, pass that carried value directly to
http_401_message and _log_401_once, and remove the error_info.partition parsing
while preserving the existing network-error behavior.
In `@tests/test_auth_errors.py`:
- Around line 46-62: Update test_login_401_reports_expired_key_not_maybe_valid
to use a specific non-generic server reason in the mocked 401 response, then
assert that exact reason appears in caplog alongside the existing assertions.
Ensure the test exercises http_401_message with server_msg rather than the
generic fallback.
In `@tests/test_sync_process.py`:
- Around line 1218-1253: The 401 sync-process test only covers plain-text
responses and misses the JSON parsing path in _server_error_message. Add a
JSON-body variant of test_persistent_401_raises_actionable_auth_error through
_post_with_retry, using an error/message payload, and assert the server-provided
expiration reason is surfaced in the resulting PlutoAuthError.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 22ff2a1b-baca-4099-bfd8-2dbb813abb70
📒 Files selected for processing (8)
CLAUDE.mdpluto/auth.pypluto/iface.pypluto/op.pypluto/sync/process.pytests/test_auth_errors.pytests/test_iface_errors.pytests/test_sync_process.py
Folds tests/test_auth_errors.py into tests/test_iface_errors.py: same subject (how a failed request is reported) and the login path shares the message builder with _try, so one file covers both. From CodeRabbit's review, all four valid: - login() built its message with no server_msg, so it always fell back to the guess even though e.response carries "API key has expired". It now parses the reason the same way _try does. This was the same gap the previous commit fixed in iface, missed here. - _logged_401 was process-wide, so a sweep creating many runs in one process would report the first bad key and then silence every later run's heartbeat/status-update/logName failures. Reset per run in ServerInterface.__init__, which is built once per run. - _try re-derived the server reason by splitting error_info, a string built for the failure log — a format change there would have silently corrupted it. Carried through the recursion as its own parameter, like last_status. - The login and sync-uploader 401 tests only covered plain-text bodies, so neither exercised the JSON parsing that real servers use. Both now have JSON-body cases asserting the server's reason survives, and a new test pins the per-run reset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrTh2LTSTNTAVSg6HXZn9A
A customer's API key expired mid-morning and they read the resulting logs as a Pluto outage. Nothing in the output pointed at their key: the client retried the 401 (transient token-validation races do clear on retry), logged
response code 401 ... from https://pluto-api..., and finally raisedFailed to create run: HTTP 401: Unauthorized.A 401 that survives every retry isn't that race — the key is expired or revoked. This says so, names the key source, and gives the command that fixes it.
Before
After
The server was already telling us why
Worth stating plainly, because it shaped the fix: the reason was on the wire the whole time and the client discarded it.
/api/slug, create-run, status, tags…){"error": "Unauthorized", "message": "API key has expired"}— also"...has been revoked","Key not found"{"code": 1002, "message": "API key has expired"}_server_error_messageread onlyerror, so it saw the status phraseUnauthorizedand nothing else. It now preferserrorwhen that carries a real reason (validation failures put it there — "A run can have at most one group:* tag.") and falls through tomessagewhenerroris only the status phrase. So the message reports the fact; themost likely expired or been revokedhedge is kept only for when the body genuinely says nothing.No server change is needed for this. The one thing the server lacks is a stable code — the match is on prose — which is noted in
CLAUDE.mdfor whoever wants to harden it later.Changes
http_401_message()(pluto/iface.py) builds the one user-facing message: names the key source (PLUTO_API_KEY/MLOP_API_TOKEN/ thepluto loginkeyring, since the fix differs), states the server's reason, and gives the command that fixes it.PlutoAuthError— aPlutoRequestErrorsubclass, so existingexcept PlutoRequestErrorhandlers keep working — is raised on retry exhaustion by bothServerInterface._tryand_SyncUploader._post_with_retry. The sync path matters because a key can expire mid-run, where the only thing the user sees isFailed to upload metrics: <error>.init()re-raises it verbatim instead of wrapping it inFailed to create run:, which implied a server problem._trylogs the message at CRITICAL — once per run, reset inServerInterface.__init__. Not once per process: a sweep creating many runs in one process would otherwise report the first bad key and then silence every later run's upload failures.login()catches this earliest, before create-run: a 401 on a pre-provided key is definitive, so it no longer reports "token may still be valid". 5xx and network failures keep that softer wording, since those genuinely say nothing about the key.Retry behavior is unchanged — 401 stays in
RETRYABLE_STATUS_CODES; only what's reported after retries are exhausted changed.Two constraints worth knowing before editing this code
Settingsholds_auth, so a string read off it — even a public URL — is credential-adjacent and shouldn't be interpolated into a log line. Log paths resolve the key page fromPLUTO_URL_APPvia_api_page_url_from_env(); the raisedPlutoAuthErrorstill carries the exactsettings.url_token, so a self-hosted deployment configured throughinit(host=...)keeps the precise URL where it matters most.http_401_message,_source_and_fix,_api_page_url_from_env,_log_401_once) and stay clear of credential words on purpose. CodeQL'spy/clear-text-logging-sensitive-dataclassifies a call's result as sensitive by the callee's name — it flagged a call taking no arguments at all — and inline# codeql[...]suppressions are not honored by this repo's setup. Renaming is what cleared it; the message text was unaffected.Tested (run the relevant ones):
bash format.sh— ruff + mypy cleanNew/updated unit tests, all offline:
tests/test_iface_errors.py—_server_error_messagereads the reason out of both real body shapes (web and ingest); the message states a server-supplied reason as fact and only hedges without one; persistent 401 raisesPlutoAuthError; the message names the right key source; an omitted page URL resolves from the environment; the non-raising path logs exactly once per run, and a second run gets its own notice. Also coverslogin(): it reports a 401 as an expired key with the server's own words, never as "may still be valid", while 5xx and unreachable-server paths keep their existing wording and don't blame the key.tests/test_sync_process.py— persistent 401 in the uploader still retries, then raises the actionable message, for both plain-text and JSON (ingest-shaped) bodies.Also verified end-to-end by stubbing a 401 from the server and calling
pluto.init()—login()logs the CRITICAL auth message andinit()raisesRuntimeErrorwith the same text.> [!NOTE]
> Low Risk
> Changes are limited to error messaging and exception typing on existing retry paths; no auth protocol or retry policy changes.
>
> Overview
> Persistent 401s are treated as bad API keys, not Pluto outages. Retry behavior for 401 is unchanged; only what users see after retries fail is different.
>
> Adds
auth_error_message()inpluto/iface.pyto build one user-facing string: which key was used (PLUTO_API_KEY, deprecatedMLOP_API_TOKEN, orpluto loginkeyring), that this is auth not an outage, a link to the API-keys page (url_tokenfor self-hosted), and how to fix it. Optional server text is appended only when it adds detail beyond generic "Unauthorized".>
> Adds
PlutoAuthError(PlutoRequestErrorsubclass).ServerInterface._tryraises it when a 401 survives all retries withraise_on_error=True; otherwise it logs the same message at CRITICAL once per process (heartbeats ~every 4s)._SyncUploader._post_with_retrydoes the same after sync retries, so keys that expire mid-run show up in upload failures.init()catchesPlutoAuthErrorand re-raises the message without a "Failed to create run" wrapper.login()logs CRITICAL withauth_error_messageon 401 for pre-provided keys instead of "token may still be valid"; 5xx/network keep softer wording.>
> Sync settings dict now includes
url_tokenfor auth error links in the child process. CLAUDE.md documents the behavior. New/updated unit tests cover login, iface, and sync uploader 401 paths.>
> <sup>Reviewed by Cursor Bugbot for commit aa9b576. Configure here.</sup>
Summary by CodeRabbit
Bug Fixes
Tests