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

  1. 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.
  2. Verify GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN in .env match the Google Cloud OAuth client.
  3. If the OAuth app is in Testing mode, either publish it or regenerate the refresh token weekly (testing tokens expire after 7 days).
  4. Confirm no whitespace/quotes are corrupting the .env values.
  5. 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

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


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)