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
- Log the raw response body (res.text()) to see what was actually returned.
- Bypass any corporate proxy/captive portal and retry.
- Verify the token endpoint URL is exactly https://oauth2.googleapis.com/token over HTTPS.
- 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
- Hit the Google token endpoint directly over HTTPS without proxy interference.
- Log unexpected response bodies in development to detect MITM/captive portals.
- Retry idempotent token refreshes with small backoff.
- Pin the exact OAuth endpoint URL and verify TLS.
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
- Gmail token refresh failed
- gmail: missing GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET /…
- Apify run returned non-array dataset payload
- gmail: could not persist processed-id state
- gmail: failed to fetch message
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)