decolua/9router · error · Error

Token exchange failed: ${error}

Error message

Token exchange failed: ${error}

What it means

GeminiCLIService.exchangeCode() (src/lib/oauth/services/gemini.js:55) POSTs the authorization code to Google's OAuth token endpoint (application/x-www-form-urlencoded with client_id/client_secret). When Google responds with a non-2xx status, the raw response body is wrapped into 'Token exchange failed: <body>' and thrown. The embedded body is Google's standard OAuth error JSON (e.g. {"error":"invalid_grant",...}).

Source

Thrown at src/lib/oauth/services/gemini.js:55

  async exchangeCode(code, redirectUri) {
    const response = await fetch(this.config.tokenUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        client_id: this.config.clientId,
        client_secret: this.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();
  }

  /**
   * Fetch project ID from Google Cloud Code Assist
   */
  async fetchProjectId(accessToken) {
    const response = await fetch(
      "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${accessToken}`,
          "Content-Type": "application/json",
          "User-Agent": "google-api-nodejs-client/9.15.1",
          "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Parse the message body's 'error' field — it names the exact OAuth failure (invalid_grant, redirect_uri_mismatch, invalid_client).
  2. For invalid_grant: restart the whole connect() flow from the browser authorization step; codes are single-use and short-lived.
  3. For redirect_uri_mismatch: ensure the identical `http://localhost:${port}/callback` used in buildAuthUrl is passed to exchangeCode.
  4. For invalid_client: check GEMINI_CONFIG clientId/clientSecret in src/lib/oauth/constants/oauth.js against the current Gemini CLI OAuth client values.
  5. Retry once on 5xx only; 4xx responses will not succeed on retry without a fresh code.

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 hint = "";
  try { hint = JSON.parse(error).error || ""; } catch {}
  if (hint === "invalid_grant") hint = " (code expired or already used — restart connect())";
  if (hint === "redirect_uri_mismatch") hint = " (redirect_uri differs from authorize step)";
  throw new Error(`Token exchange failed: ${error}${hint}`);
}
Defensive patterns

Strategy: try-catch

Validate before calling

// pre-checks before exchange
if (!code) throw new Error("No authorization code to exchange");
if (!redirectUri.startsWith("http://localhost:")) throw new Error("redirect_uri must match the authorize-step localhost URI");

Type guard

function isOAuthErrorBody(text) {
  try { const b = JSON.parse(text); return typeof b.error === "string"; } catch { return false; }
}

Try / catch

try {
  const tokens = await geminiService.exchangeCode(code, redirectUri);
} catch (err) {
  const m = /^Token exchange failed: (.*)/.exec(err.message);
  if (m) {
    const reason = (() => { try { return JSON.parse(m[1]).error; } catch { return m[1]; } })();
    if (reason === "invalid_grant") console.error("Code expired/used — restart connect() from the browser step.");
    else if (reason === "redirect_uri_mismatch") console.error("Pass the exact same redirect_uri used in the authorize URL.");
    else if (reason === "invalid_client") console.error("Bundled client credentials are stale — update GEMINI_CONFIG.");
    else console.error("Token exchange failed:", reason);
  } else throw err;
}

Prevention

When it happens

Trigger: Calling exchangeCode(code, redirectUri) during connect() when Google returns 4xx — 'invalid_grant' (code already used or expired, ~10 min lifetime, single-use), 'redirect_uri_mismatch' (redirectUri differs from the one used in the authorize step), 'invalid_client' (bundled GEMINI_CONFIG client credentials changed), or a 5xx from Google.

Common situations: Re-running connect() after the callback was already consumed; the local callback server restarted on a different port between authorize and exchange so the redirect_uri no longer matches; authorization code pasted late and expired; Google rotated the embedded CLI OAuth client credentials.

Related errors


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