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

  1. Parse the JSON error body in the message (e.g. invalid_grant means the code expired) and restart the auth flow
  2. Confirm the redirect_uri port matches the local callback server
  3. Re-run `happy connect` Gemini authentication end-to-end
  4. 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

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


AI-assisted analysis of slopus/happy@b824cd0a46 (2026-08-31). Data as JSON: /api/errors/96619c416515b5ac. Report an issue: GitHub.