slopus/happy · error
Token exchange failed: ${error}
Error message
Token exchange failed: ${error} What it means
Thrown by exchangeCodeForTokens for the Gemini (Google) OAuth flow when the token endpoint responds non-OK. The raw response body is included in the message, so the Google error JSON (e.g. invalid_grant) is visible.
Source
Thrown at packages/happy-cli/src/commands/connect/authenticateGemini.ts:109
): Promise<GeminiAuthTokens> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: code,
code_verifier: verifier,
redirect_uri: `http://localhost:${port}/oauth2callback`,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
const data = await response.json() as GeminiAuthTokens;
return data;
}
/**
* Start local server to handle OAuth callback
*/
async function startCallbackServer(
state: string,
verifier: string,
port: number
): Promise<GeminiAuthTokens> {
return new Promise((resolve, reject) => {
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url!, `http://localhost:${port}`);
View on GitHub (pinned to b824cd0a46)
Solutions
- Parse the JSON error body in the message (e.g. invalid_grant means the code expired) and restart the auth flow
- Confirm the redirect_uri port matches the local callback server
- Re-run `happy connect` Gemini authentication end-to-end
- Verify OAuth client credentials are still valid in Google Cloud Console
Example fix
// before
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
// after
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed (HTTP ${response.status}): ${error}`);
} Defensive patterns
Strategy: try-catch
Validate before calling
// Ensure a code exists and callback port matches redirect_uri
if (!authCode) throw new Error('No authorization code captured');
if (!redirectUri.includes('/oauth2callback')) throw new Error('Wrong Gemini redirect path'); Try / catch
try {
const tokens = await exchangeCodeForTokens(code, port);
} catch (err) {
if (String(err.message).startsWith('Token exchange failed')) {
// body usually contains Google error like invalid_grant
console.error('Gemini auth error:', err.message);
await runGeminiAuth(); // fresh code
} else throw err;
} Prevention
- Approve the consent screen quickly to avoid code expiry
- Re-run the full flow instead of retrying a used code
- Verify OAuth client credentials in Google Cloud Console
- Confirm the /oauth2callback port is free and consistent
When it happens
Trigger: POST to Google's oauth2 token endpoint with the authorization code and redirect_uri http://localhost:<port>/oauth2callback returns !response.ok — expired/used code, code_verifier mismatch, revoked consent, or quota/5xx.
Common situations: User took too long to approve consent (code expired), re-ran flow reusing a code, redirect port mismatch, or Google rejecting due to changed OAuth client settings.
Related errors
- Token exchange failed: ${tokenResponse.statusText}
- Token exchange failed: ${error}
- Token exchange failed
- Authentication failed
- Invalid state parameter
AI-assisted analysis of slopus/happy@b824cd0a46 (2026-08-31).
Data as JSON: /api/errors/96619c416515b5ac.
Report an issue: GitHub.