decolua/9router · error · Error

`Token exchange failed: ${error}`

Error message

`Token exchange failed: ${error}`

What it means

Gemini CLI OAuth token exchange: the POST trading the authorization code (plus redirect_uri) for tokens returned a non-2xx status. The body text is interpolated into this Error. Because mapTokens/postExchange never run, no Gemini credentials are stored and the login fails.

Source

Thrown at src/lib/oauth/providers/gemini-cli.js:36

  exchangeToken: async (config, code, redirectUri) => {
    const response = await fetch(config.tokenUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        client_id: config.clientId,
        client_secret: config.clientSecret,
        code: code,
        redirect_uri: redirectUri,
      }),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Token exchange failed: ${error}`);
    }

    return await response.json();
  },
  postExchange: async (tokens) => {
    // Fetch user info
    const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
      headers: { Authorization: `Bearer ${tokens.access_token}` },
    });
    const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};

    // Fetch project ID
    let projectId = "";
    try {
      const projectRes = await fetch(
        "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
        {
          method: "POST",

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Read the body's 'error' field to distinguish invalid_grant vs invalid_client vs server errors.
  2. Sync system clock (NTP) and restart the login flow to get a fresh code.
  3. Ensure the redirect_uri in the exchange matches the authorize request exactly.
  4. Check Google OAuth client config / Gemini CLI version for changed client credentials.

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();
  let code = "unknown";
  try { code = JSON.parse(error).error; } catch {}
  throw new Error(`Token exchange failed (HTTP ${response.status}, ${code}): ${error}`);
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-exchange checks for Gemini login
if (!code) throw new Error("No authorization code for Gemini exchange");
if (Math.abs(Date.now() - (await ntpOffset())) > 90_000) throw new Error("Clock skew too large for Google OAuth — sync NTP");

Type guard

function isGoogleOAuthError(body) {
  return typeof body === "object" && body !== null &&
    typeof body.error === "string"; // e.g. "invalid_grant"
}

Try / catch

try {
  const tokens = await geminiProvider.exchangeCode(code, redirectUri);
} catch (err) {
  if (String(err.message).includes("Token exchange failed")) {
    if (/invalid_grant/.test(err.message)) {
      console.error("Gemini code expired/replayed or clock skew — restart login");
      await restartGeminiLogin();
    } else {
      console.error("Gemini token endpoint error:", err.message);
    }
  } else throw err;
}

Prevention

When it happens

Trigger: Calling exchangeCode for gemini-cli when Google's OAuth token endpoint responds !response.ok — invalid_grant (code expired, already used, or clock skew), invalid_client, redirect_uri mismatch, or transient 5xx/429.

Common situations: Machine clock skewed (Google rejects grants); user delayed past the code's ~10-minute life; redirect_uri differs between authorize and exchange (e.g. different port on localhost); stale/revoked OAuth client; Google outage.

Related errors


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/885084b64ec86d0b. Report an issue: GitHub.