{"record":{"id":"5e27501914c8b37f","repo":"googleworkspace/cli","slug":"token-refresh-failed-with-status","errorCode":null,"errorMessage":"Token refresh failed with status {}: {}","messagePattern":"Token refresh failed with status (.+?): (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/google-workspace-cli/src/auth.rs","lineNumber":72,"sourceCode":"    let client = crate::client::shared_client().map_err(anyhow::Error::from)?;\n    let params = [\n        (\"client_id\", client_id),\n        (\"client_secret\", client_secret),\n        (\"refresh_token\", refresh_token),\n        (\"grant_type\", \"refresh_token\"),\n    ];\n\n    let response = client\n        .post(\"https://oauth2.googleapis.com/token\")\n        .form(&params)\n        .send()\n        .await\n        .context(\"Failed to send token refresh request\")?;\n\n    if !response.status().is_success() {\n        let status = response.status();\n        let body = response_text_or_placeholder(response.text().await);\n        anyhow::bail!(\"Token refresh failed with status {}: {}\", status, body);\n    }\n\n    let token_response: TokenResponse = response\n        .json()\n        .await\n        .context(\"Failed to parse token response\")?;\n\n    Ok(token_response.access_token)\n}\n\n/// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header).\n///\n/// Priority:\n/// 1. `GOOGLE_WORKSPACE_PROJECT_ID` environment variable.\n/// 2. `project_id` from the OAuth client configuration (`client_secret.json`).\n/// 3. `quota_project_id` from Application Default Credentials (ADC).\npub fn get_quota_project() -> Option<String> {\n    // 1. Explicit environment variable (highest priority)","sourceCodeStart":54,"sourceCodeEnd":90,"githubUrl":"https://github.com/googleworkspace/cli/blob/a3768d0e82ad83cca2da97724e46bea4ff0e6dbd/crates/google-workspace-cli/src/auth.rs#L54-L90","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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","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","If status is 5xx or the body mentions proxy/TLS errors, retry after a short delay and check HTTP(S)_PROXY environment variables","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"],"exampleFix":"// before: treating every refresh failure identically\nmatch refresh_token_with_reqwest(&id, &secret, &rt).await {\n    Ok(t) => t,\n    Err(_) => panic!(\"auth failed\"),\n}\n\n// after: branch on the reported status/body to distinguish re-auth from transient failure\nlet msg = err.to_string();\nif msg.contains(\"400\") && msg.contains(\"invalid_grant\") {\n    eprintln!(\"Session revoked — run `gws auth login` to re-authenticate\");\n} else if msg.contains(\"5\") || msg.contains(\"503\") {\n    // transient: safe to retry after backoff\n}","handlingStrategy":"try-catch","validationCode":"// Pre-flight: confirm the refresh token is present and non-empty before any call\nfn has_refresh_token(cred: &serde_json::Value) -> bool {\n    cred.get(\"refresh_token\").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty())\n}","typeGuard":null,"tryCatchPattern":"match refresh_token_with_reqwest(&id, &secret, &rt).await {\n    Ok(tok) => { /* proceed */ }\n    Err(e) => {\n        let m = e.to_string();\n        if m.contains(\"invalid_grant\") {\n            // terminal: prompt re-login, do NOT retry\n        } else if m.contains(\"invalid_client\") {\n            // terminal: client credentials wrong, surface config error\n        } else {\n            // transient (5xx/proxy): retry with backoff, cap attempts\n        }\n    }\n}","preventionTips":["Keep the OAuth client (client_id/secret) that issued a refresh token stable; re-issue tokens via login whenever the client changes","Refresh tokens expire after ~6 months of non-use — schedule a periodic authenticated call in long-lived setups","Set HTTP_PROXY/HTTPS_PROXY correctly so the reqwest fallback path uses the right proxy","Log (never print) the token endpoint response body on failure — it distinguishes invalid_grant from invalid_client instantly"],"tags":["oauth","token-refresh","auth","http"],"backgroundTag":"oauth-refresh-token-failed","analyzedSha":"a3768d0e82ad83cca2da97724e46bea4ff0e6dbd","analyzedAt":"2026-08-16T19:51:46.516Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}