santifer/career-ops · error · Error

Gmail token refresh returned no access_token

Error message

Gmail token refresh returned no access_token

What it means

After a successful (2xx) token refresh response, getAccessToken parses the JSON and requires an access_token field. If Google's response lacks it despite being ok, the library throws this error since it cannot authenticate subsequent Gmail API calls.

Solutions

  1. Log the raw response body (res.text()) to see what was actually returned.
  2. Bypass any corporate proxy/captive portal and retry.
  3. Verify the token endpoint URL is exactly https://oauth2.googleapis.com/token over HTTPS.
  4. Retry — if transient, a second refresh typically returns a valid access_token.

Example fix

// before
const data = await res.json();
if (!data.access_token) throw new Error('Gmail token refresh returned no access_token');
// after
const data = await res.json();
if (!data.access_token) {
  console.error('unexpected token response keys:', Object.keys(data));
  throw new Error('Gmail token refresh returned no access_token');
}
Defensive patterns

Strategy: retry

Try / catch

try {
  const token = await getAccessToken();
} catch (e) {
  if (String(e.message).includes('no access_token')) {
    // retry once; if persistent, log raw body and bypass proxy
    return retryWithBackoff(getAccessToken, 2);
  }
  throw e;
}

Prevention

When it happens

Trigger: Google returns 200 with a JSON body that has no access_token — unexpected API response, proxy interception, or a non-token JSON payload (e.g. an HTML-captured login page served with 200).

Common situations: Corporate proxy/captive portal returning a 200 HTML page; grant_type silently ignored due to a misconfigured request; Google API behavior change; response body actually an error description with a 200 status from a man-in-the-middle.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/d819801dab7eb5a1. Report an issue: GitHub.

Appendix: source

Thrown at plugins/gmail/index.mjs:47

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 {
    mkdirSync('data', { recursive: true });
    writeFileSync(STATE_PATH, JSON.stringify({ processed_message_ids: [...ids] }, null, 2), 'utf-8');
  } catch (err) {

View on GitHub (pinned to aac998c7ed)