Hmbown/CodeWhale · error
OAuth failed permanently ( ). Sign in again with ` `.
Error message
{name} OAuth {operation} failed permanently ({err}). Sign in again with `{}`. What it means
Thrown when an OAuth refresh/exchange operation fails with a permanently unrecoverable condition: the token endpoint returned `invalid_grant` (or a provider-specific refresh-token-reused/expired/invalidated error) or HTTP 401. The message includes the provider name, operation, underlying error, and the exact command to sign in again.
Solutions
- Sign in again using the printed relogin command/hint to obtain fresh tokens
- Stop retrying with the old refresh token — reuse attempts trigger further invalidation
- If this happens repeatedly across machines, ensure only one client refreshes at a time (token rotation conflicts)
- Check provider session/consent revocation in the provider's admin/user dashboard
Example fix
// before
let token = refresh_access_token(provider, stored_refresh)?; // permanent failure
// after
let token = match refresh_access_token(provider, stored_refresh) {
Ok(t) => t,
Err(e) if e.to_string().contains("failed permanently") => {
eprintln!("credentials stale; running interactive re-login");
run_login(provider).await?;
refresh_access_token(provider, freshly_stored_refresh(provider))?
}
Err(e) => return Err(e),
}; Defensive patterns
Strategy: try-catch
Validate before calling
// detect a stale refresh token before it poisons the flow
if (refreshTokenAge(provider) > maxRefreshTokenLifetime) {
triggerReLogin(provider);
} Try / catch
try {
await refreshAccessToken(provider);
} catch (e) {
if (String(e).includes('failed permanently')) {
await interactiveReLogin(provider); // tokens are unrecoverable
} else {
retryWithBackoff(e);
}
} Prevention
- Never retry after a permanent refresh failure — further attempts trigger reuse-detection
- Ensure only one client/process refreshes a given token at a time
- Refresh proactively before token expiry to avoid revocation surprises
- Surface the printed relogin command to users instead of swallowing the error
When it happens
Trigger: Calling the refresh/exchange path when the stored refresh token is expired, revoked, already used (reuse detection), or otherwise rejected — detected via error code match or HTTP status 401 in the token response.
Common situations: Refresh token rotated elsewhere (second client consumed it); long-idle session exceeded the provider's refresh-token lifetime; admin revoked the session; provider reuse-detection invalidated the token after a duplicate refresh.
Related errors
- MCP server rejected the request with and refreshing the…
- {}
- OAuth failed ( )
- OAuth bearer credentials are not an API key
- OpenAI Codex uses OAuth. Sign in with ChatGPT via…
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/79c95672b00f4256.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/oauth.rs:986
body: &str,
operation: &str,
params: &OAuthProviderParams,
) -> Result<OAuthTokenMaterial> {
let name = params.display_name;
let parsed: OAuthTokenMaterial = serde_json::from_str(body).map_err(|_| {
anyhow::anyhow!("{name} OAuth {operation} returned HTTP {status} that was not token JSON")
})?;
if !(200..300).contains(&status) || parsed.error.is_some() {
let err = parsed.error.as_deref().unwrap_or("token_error");
if matches!(
err,
"invalid_grant"
| "refresh_token_reused"
| "refresh_token_expired"
| "refresh_token_invalidated"
) || status == 401
{
bail!(
"{name} OAuth {operation} failed permanently ({err}). Sign in again with `{}`.",
params.relogin_hint
);
}
bail!("{name} OAuth {operation} failed ({err})");
}
anyhow::ensure!(
parsed
.access_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty()),
"{name} OAuth {operation} returned an empty access token"
);
Ok(parsed)
}
fn compact_form_error(body: &str) -> String {
body.chars().filter(|c| !c.is_control()).take(80).collect()View on GitHub (pinned to 73e0f67d83)