zed-industries/zed · error

Token refresh failed

Error message

Token refresh failed (HTTP {}): {}

What it means

Raised in `refresh_token` when the refresh-token grant request to the OAuth provider returns a non-success HTTP status. The response body is redacted before inclusion so leaked credentials never appear in the error. Depending on the status, the caller classifies this as transient (retryable) or permanent (e.g. refresh token revoked, requiring re-authentication).

Solutions

  1. Check the (redacted) body for 'invalid_grant' or 'invalid_client' — if present, discard stored tokens and restart the full OAuth flow
  2. For 429/5xx statuses, retry with exponential backoff, since the code classifies these as transient
  3. Confirm the refresh token in storage is current (rotation providers invalidate the old token on each use)
  4. Check for concurrent refreshes racing each other and consuming rotated tokens; serialize refresh calls
  5. Verify client credentials haven't changed on the provider dashboard

Example fix

// before
let err = anyhow!("Token refresh failed (HTTP {}): {}", status, redact_token_body(&body));
// after
let err = anyhow!("Token refresh failed (HTTP {}): {}", status, redact_token_body(&body));
if status.is_server_error() || status == http_client::StatusCode::TOO_MANY_REQUESTS {
    return Err(RefreshError::Transient(err));
}
return Err(RefreshError::Fatal(err));
Defensive patterns

Strategy: retry

Validate before calling

// before refreshing
assert!(!refresh_token.expose_secret().is_empty(), "no stored refresh token");
// skip refresh if access token still has >=60s lifetime
if access_token_expires_at > now + Duration::from_secs(60) { return Ok(access_token); }

Try / catch

match get_fresh_credentials(cx).await {
    Ok(creds) => creds,
    Err(RefreshError::Transient(e)) => {
        // retry with exponential backoff for 429/5xx
        backoff_retry(|| get_fresh_credentials(cx), 3).await?
    }
    Err(RefreshError::Fatal(e)) => {
        // refresh token revoked/expired: clear stored tokens, prompt re-auth
        clear_stored_tokens();
        return Err(e);
    }
}

Prevention

When it happens

Trigger: Sending the refresh_token grant to the token endpoint and receiving 400/401 (invalid_grant — refresh token expired, rotated, or revoked) or 5xx/429 (provider outage or rate limiting).

Common situations: User revoked the app or the provider rotated/invalidated the refresh token, tokens idle past the provider's max lifetime, provider-side outages returning 5xx, hitting rate limits with too-frequent refreshes.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of zed-industries/zed@916fc2b8cb (2026-09-19). Data as JSON: /api/errors/66062a39be7f19c4. Report an issue: GitHub.

Appendix: source

Thrown at crates/x_ai_subscribed/src/x_ai_subscribed.rs:913

    let request = HttpRequest::builder()
        .method(Method::POST)
        .uri(XAI_TOKEN_URL)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(AsyncBody::from(body))
        .map_err(|e| RefreshError::Transient(e.into()))?;

    let mut response = client
        .send(request)
        .await
        .map_err(RefreshError::Transient)?;
    let status = response.status();
    let mut body = String::new();
    smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body)
        .await
        .map_err(|e| RefreshError::Transient(e.into()))?;

    if !status.is_success() {
        let err = anyhow!(
            "Token refresh failed (HTTP {}): {}",
            status,
            redact_token_body(&body)
        );
        if status == http_client::StatusCode::BAD_REQUEST
            || status == http_client::StatusCode::UNAUTHORIZED
            || status == http_client::StatusCode::FORBIDDEN
        {
            return Err(RefreshError::Fatal(err));
        }
        return Err(RefreshError::Transient(err));
    }

    serde_json::from_str(&body).map_err(|e| RefreshError::Transient(e.into()))
}

fn extract_email_claim(jwt: &str) -> Option<String> {
    let payload_b64 = jwt.split('.').nth(1)?;

View on GitHub (pinned to 916fc2b8cb)