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.
This commit is contained in:
iris committed 2026-09-05 11:58:17 -04:00
1 parent 6bdec6e785
commit 7b63330aaa
1 file changed
+30 -9
+30 -9
View File
@@ -206,15 +206,8 @@ impl UsageProvider for ClaudeUsage {
.and_then(|mut response| response.body_mut().read_to_string()) .and_then(|mut response| response.body_mut().read_to_string())
{ {
Ok(text) => text, Ok(text) => text,
Err(err) => { // The error string can embed the URL but never the token.
// The error string can embed the URL but never the token. Err(err) => return self.snapshot(UsageState::Failed { detail: why(&err) }, Vec::new()),
return self.snapshot(
UsageState::Failed {
detail: format!("usage endpoint unreachable: {err}"),
},
Vec::new(),
);
}
}; };
let body: Value = match serde_json::from_str(&text) { let body: Value = match serde_json::from_str(&text) {
Ok(body) => body, 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. /// Which kind of "no credentials" a failed read was.
/// ///
/// The distinction is the point of having both states. `cat` failing because /// The distinction is the point of having both states. `cat` failing because
@@ -631,6 +643,15 @@ mod tests {
use super::*; use super::*;
use crate::config::DriverKind; 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] #[test]
fn parses_the_limits_array_defensively() { fn parses_the_limits_array_defensively() {
// Trimmed from a live 2026-08-24 response. // Trimmed from a live 2026-08-24 response.