Kuberwastaken/claurst · error
Token refresh failed
Error message
Token refresh failed ({}): {} What it means
Raised in the public refresh_oauth_token when the refresh-token POST to the OAuth token endpoint returns a non-success HTTP status. The HTTP status and response body (typically containing grant_type/invalid_grant details from the OAuth server) are embedded in the message. Callers rely on this to silently refresh expired access tokens, so this error surfaces when the stored refresh credential is no longer acceptable.
Solutions
- Treat this as 'session fully expired': re-run the OAuth login flow to obtain fresh access and refresh tokens.
- If refresh tokens are single-use, ensure only one process refreshes at a time and the new refresh token is persisted atomically after each refresh.
- Check the embedded status/body: 400 invalid_grant means revocation/expiry — no retry will help; 5xx is transient and worth retrying with backoff.
- Verify client_id/client_secret and the token endpoint URL are current.
- Confirm the system clock is correct — large skew can invalidate token validation.
Example fix
// before: any failure aborts with a raw HTTP message
if !resp.status().is_success() {
bail!("Token refresh failed ({}): {}", resp.status(), resp.text().await.unwrap_or_default());
}
// after: distinguish transient from permanent failures for callers
let status = resp.status();
if status.is_server_error() {
bail!("Token refresh temporarily failed ({}) — retry later", status);
}
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
if text.contains("invalid_grant") {
bail!("Refresh token revoked or expired — please log in again");
}
bail!("Token refresh failed ({}): {}", status, text);
} Defensive patterns
Strategy: fallback
Validate before calling
// Before refreshing, confirm a refresh token exists and is plausibly unexpired
fn can_refresh(tokens: &OAuthTokens) -> bool {
tokens.refresh_token.as_deref().map_or(false, |t| !t.is_empty())
} Type guard
fn is_permanent_refresh_failure(body: &str) -> bool {
// invalid_grant = revoked/rotated/expired — re-login required
body.contains("invalid_grant") || body.contains("invalid_client")
} Try / catch
match refresh_oauth_token(&refresh_token).await {
Ok(new_tokens) => new_tokens,
Err(e) if e.to_string().contains("invalid_grant") => {
eprintln!("Session expired — starting interactive login...");
run_oauth_login_flow().await?
}
Err(e) if is_transient(&e) => {
backoff_retry(|| refresh_oauth_token(&refresh_token), 3).await?
}
Err(e) => return Err(e),
} Prevention
- Persist rotated single-use refresh tokens atomically immediately after each refresh
- Serialize refreshes across processes with a lock to avoid racing the rotation
- Refresh proactively before access-token expiry instead of on 401
- Fall back to interactive login when the refresh token is permanently rejected
- Verify system clock accuracy on hosts performing refreshes
When it happens
Trigger: refresh_oauth_token sends the stored refresh_token with grant_type=refresh_token and receives !resp.status().is_success(): the refresh token was revoked, rotated (single-use refresh tokens — an old one was replayed), expired after provider inactivity windows, or the client credentials/endpoint config is wrong.
Common situations: Developers hit this after switching machines or logging in elsewhere (revoking the old session), after clock/restore scenarios that invalidate tokens, when multiple processes refresh concurrently and race the single-use rotation, or after the provider shortens refresh-token lifetimes.
Related errors
- Bridge register: server returned
- Token exchange failed
- Login succeeded but could not obtain a usable credential
- Token exchange failed
- API key creation failed
AI-assisted analysis of Kuberwastaken/claurst@b0637c97ec (2026-09-10).
Data as JSON: /api/errors/f9b54f1381c912db.
Report an issue: GitHub.
Appendix: source
Thrown at src-rust/crates/cli/src/oauth_flow.rs:472
"scope": oauth::ALL_SCOPES.join(" "),
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let resp = client
.post(oauth::TOKEN_URL)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.context("Token refresh HTTP request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("Token refresh failed ({}): {}", status, text);
}
let token_resp: TokenExchangeResponse = resp.json().await?;
let expires_at_ms = chrono::Utc::now().timestamp_millis()
+ (token_resp.expires_in as i64 * 1000);
let scopes: Vec<String> = token_resp
.scope
.as_deref()
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect();
let mut updated = tokens.clone();
updated.access_token = token_resp.access_token;
if let Some(new_rt) = token_resp.refresh_token {
updated.refresh_token = Some(new_rt);
View on GitHub (pinned to b0637c97ec)