googleworkspace/cli · error

Token refresh failed with status {}: {}

Error message

Token refresh failed with status {}: {}

What it means

This error comes from refresh_token_with_reqwest(), the fallback refresh path used when yup-oauth2's hyper-based client fails (typically behind HTTP proxies). It POSTs grant_type=refresh_token with client_id, client_secret, and refresh_token to https://oauth2.googleapis.com/token and fires when the endpoint answers with a non-2xx status. The status code and raw response body are embedded in the message, so the underlying OAuth error (invalid_grant, invalid_client, etc.) is visible in the text.

Source

Thrown at crates/google-workspace-cli/src/auth.rs:72

    let client = crate::client::shared_client().map_err(anyhow::Error::from)?;
    let params = [
        ("client_id", client_id),
        ("client_secret", client_secret),
        ("refresh_token", refresh_token),
        ("grant_type", "refresh_token"),
    ];

    let response = client
        .post("https://oauth2.googleapis.com/token")
        .form(&params)
        .send()
        .await
        .context("Failed to send token refresh request")?;

    if !response.status().is_success() {
        let status = response.status();
        let body = response_text_or_placeholder(response.text().await);
        anyhow::bail!("Token refresh failed with status {}: {}", status, body);
    }

    let token_response: TokenResponse = response
        .json()
        .await
        .context("Failed to parse token response")?;

    Ok(token_response.access_token)
}

/// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header).
///
/// Priority:
/// 1. `GOOGLE_WORKSPACE_PROJECT_ID` environment variable.
/// 2. `project_id` from the OAuth client configuration (`client_secret.json`).
/// 3. `quota_project_id` from Application Default Credentials (ADC).
pub fn get_quota_project() -> Option<String> {
    // 1. Explicit environment variable (highest priority)

View on GitHub (pinned to a3768d0e82)

Solutions

  1. Read the body in the message: invalid_grant means the refresh token is dead — run `gws auth logout && gws auth login` to mint a new one
  2. If the body says invalid_client, verify the saved client_secret.json / GOOGLE_WORKSPACE_CLI_CLIENT_ID/SECRET still match the GCP OAuth client that issued the token
  3. If status is 5xx or the body mentions proxy/TLS errors, retry after a short delay and check HTTP(S)_PROXY environment variables
  4. Confirm the account still has the required scopes granted and that the OAuth consent screen has not been reverted to testing with the user removed

Example fix

// before: treating every refresh failure identically
match refresh_token_with_reqwest(&id, &secret, &rt).await {
    Ok(t) => t,
    Err(_) => panic!("auth failed"),
}

// after: branch on the reported status/body to distinguish re-auth from transient failure
let msg = err.to_string();
if msg.contains("400") && msg.contains("invalid_grant") {
    eprintln!("Session revoked — run `gws auth login` to re-authenticate");
} else if msg.contains("5") || msg.contains("503") {
    // transient: safe to retry after backoff
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight: confirm the refresh token is present and non-empty before any call
fn has_refresh_token(cred: &serde_json::Value) -> bool {
    cred.get("refresh_token").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty())
}

Try / catch

match refresh_token_with_reqwest(&id, &secret, &rt).await {
    Ok(tok) => { /* proceed */ }
    Err(e) => {
        let m = e.to_string();
        if m.contains("invalid_grant") {
            // terminal: prompt re-login, do NOT retry
        } else if m.contains("invalid_client") {
            // terminal: client credentials wrong, surface config error
        } else {
            // transient (5xx/proxy): retry with backoff, cap attempts
        }
    }
}

Prevention

When it happens

Trigger: Google returns 400 invalid_grant when the stored refresh token was revoked, expired (6 months unused), or was issued to a different OAuth client; 401 invalid_client when client_id/client_secret no longer match the client that authorized the token (secret rotated or OAuth client deleted); 5xx on transient Google endpoint outages. Also triggered when a corporate proxy intercepts oauth2.googleapis.com and returns its own error page.

Common situations: User clicked 'Remove access' for the app in their Google Account security page; client_secret.json was replaced with credentials from a different GCP OAuth client than the one that produced the stored refresh token; refresh token unused for >6 months; proxy or TLS-inspection appliance rewriting the token endpoint response; system clock far in the past/future.

Related errors


AI-assisted analysis of googleworkspace/cli@a3768d0e82 (2026-08-16). Data as JSON: /api/errors/5e27501914c8b37f. Report an issue: GitHub.