santifer/career-ops · error · Error
Gmail token refresh failed
Error message
Gmail token refresh failed: ${res.status} ${(await res.text()).slice(0, 200)} What it means
getAccessToken exchanges a Gmail OAuth refresh token for an access token via Google's token endpoint. If the HTTP response is not ok, it throws with the status code and the first 200 chars of the error body.
Solutions
- Read the error body in the message — invalid_grant usually means the refresh token is expired/revoked: re-run the OAuth consent flow to get a new refresh token.
- Verify GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN in .env match the Google Cloud OAuth client.
- If the OAuth app is in Testing mode, either publish it or regenerate the refresh token weekly (testing tokens expire after 7 days).
- Confirm no whitespace/quotes are corrupting the .env values.
- Check clock skew on the machine — invalid_grant can be caused by large time drift.
Example fix
// before refresh_token: refreshToken, // token revoked (app in Testing mode) // after // regenerate via OAuth playground / consent flow with your published client: refresh_token: process.env.GMAIL_REFRESH_TOKEN, // freshly issued token // and move the OAuth app out of Testing mode or re-consent every 7 days
Defensive patterns
Strategy: retry
Validate before calling
const creds = ['GMAIL_CLIENT_ID','GMAIL_CLIENT_SECRET','GMAIL_REFRESH_TOKEN'];
if (creds.some(k => !process.env[k])) throw new Error('missing Gmail OAuth credentials'); Try / catch
try {
const token = await getAccessToken();
} catch (e) {
if (String(e.message).includes('token refresh failed')) {
if (e.message.includes('invalid_grant')) {
console.error('Refresh token expired/revoked — re-run OAuth consent flow');
}
// transient 5xx: retry with backoff
return retryWithBackoff(getAccessToken, 3);
}
throw e;
} Prevention
- Publish the OAuth app (or re-consent weekly while in Testing mode).
- Store the refresh token securely and regenerate after rotating client secrets.
- Log the first 200 chars of error bodies to distinguish invalid_grant from transient errors.
- Keep .env values free of stray quotes/whitespace.
When it happens
Trigger: POST to https://oauth2.googleapis.com/token returns 400/401/403 — invalid or revoked refresh token, wrong client_id/client_secret, malformed grant request, or account access revoked.
Common situations: Refresh token revoked because the app is in 'Testing' mode and the token expired after 7 days; client secret rotated or mismatched with the one used to issue the refresh token; typo'd credentials in .env; Google returning invalid_grant.
Related errors
- Gmail token refresh returned no access_token
- gmail: missing GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET /…
- gmail: could not persist processed-id state
- gmail: failed to fetch message
- gmail: invalid days_back
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/9c2cea83b3d20932.
Report an issue: GitHub.
Appendix: source
Thrown at plugins/gmail/index.mjs:44
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me';
const STATE_PATH = 'data/gmail-state.json'; // the plugin's own processed-id cursor
/** Exchange the long-lived refresh token for a short-lived access token. */
async function getAccessToken({ clientId, clientSecret, refreshToken }, fetchFn = globalThis.fetch) {
const res = await fetchFn(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
if (!res.ok) {
throw new Error(`Gmail token refresh failed: ${res.status} ${(await res.text()).slice(0, 200)}`);
}
const data = await res.json();
if (!data.access_token) throw new Error('Gmail token refresh returned no access_token');
return data.access_token;
}
function loadProcessedIds() {
if (!existsSync(STATE_PATH)) return new Set();
try {
const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'));
return new Set(state.processed_message_ids || []);
} catch {
return new Set();
}
}
function saveProcessedIds(ids) {
try {View on GitHub (pinned to aac998c7ed)