From 7b63330aaad94520860c79342276af0631a9b453 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 11:58:17 -0400 Subject: [PATCH] Say when a usage 401 is an expired login, not an unreachable endpoint A 401 is the endpoint answering and refusing the stored OAuth token, which Claude Code refreshes as it runs -- so a machine whose CLI has been idle hands us a stale one. Reporting it as "usage endpoint unreachable" pointed at the network instead of at the one thing that fixes it. --- server/src/usage.rs | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/server/src/usage.rs b/server/src/usage.rs index 214e7a1..78d20ea 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -206,15 +206,8 @@ impl UsageProvider for ClaudeUsage { .and_then(|mut response| response.body_mut().read_to_string()) { Ok(text) => text, - Err(err) => { - // The error string can embed the URL but never the token. - return self.snapshot( - UsageState::Failed { - detail: format!("usage endpoint unreachable: {err}"), - }, - Vec::new(), - ); - } + // The error string can embed the URL but never the token. + Err(err) => return self.snapshot(UsageState::Failed { detail: why(&err) }, Vec::new()), }; let body: Value = match serde_json::from_str(&text) { Ok(body) => body, @@ -231,6 +224,25 @@ impl UsageProvider for ClaudeUsage { } } +/// What a failed call to the usage endpoint should say. +/// +/// A status is not a network fault and must not be reported as one: the +/// endpoint answered, and 401 in particular says the stored token has expired +/// -- Claude Code refreshes it as it runs, so a machine whose CLI has been +/// idle long enough hands us a stale one. That is fixable, and the message is +/// the only place anybody finds out how. +fn why(err: &ureq::Error) -> String { + match err { + ureq::Error::StatusCode(401) => { + format!( + "the Claude login on this machine has expired (401); run `claude` there, or re-run `/login`, to refresh {CREDENTIALS}" + ) + } + ureq::Error::StatusCode(code) => format!("usage endpoint refused the request: HTTP {code}"), + other => format!("usage endpoint unreachable: {other}"), + } +} + /// Which kind of "no credentials" a failed read was. /// /// The distinction is the point of having both states. `cat` failing because @@ -631,6 +643,15 @@ mod tests { use super::*; use crate::config::DriverKind; + #[test] + fn an_expired_login_is_not_reported_as_an_unreachable_endpoint() { + let stale = why(&ureq::Error::StatusCode(401)); + assert!(stale.contains("expired"), "{stale}"); + assert!(!stale.contains("unreachable"), "{stale}"); + assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500")); + assert!(why(&ureq::Error::HostNotFound).contains("unreachable")); + } + #[test] fn parses_the_limits_array_defensively() { // Trimmed from a live 2026-08-24 response.