{"record":{"id":"8f1aa3777269dd5c","repo":"decolua/9router","slug":"token-exchange-failed-error-8f1aa3","errorCode":null,"errorMessage":"Token exchange failed: ${error}","messagePattern":"Token exchange failed: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/lib/oauth/services/gemini.js","lineNumber":55,"sourceCode":"  async exchangeCode(code, redirectUri) {\n    const response = await fetch(this.config.tokenUrl, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        Accept: \"application/json\",\n      },\n      body: new URLSearchParams({\n        grant_type: \"authorization_code\",\n        client_id: this.config.clientId,\n        client_secret: this.config.clientSecret,\n        code: code,\n        redirect_uri: redirectUri,\n      }),\n    });\n\n    if (!response.ok) {\n      const error = await response.text();\n      throw new Error(`Token exchange failed: ${error}`);\n    }\n\n    return await response.json();\n  }\n\n  /**\n   * Fetch project ID from Google Cloud Code Assist\n   */\n  async fetchProjectId(accessToken) {\n    const response = await fetch(\n      \"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist\",\n      {\n        method: \"POST\",\n        headers: {\n          \"Authorization\": `Bearer ${accessToken}`,\n          \"Content-Type\": \"application/json\",\n          \"User-Agent\": \"google-api-nodejs-client/9.15.1\",\n          \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",","sourceCodeStart":37,"sourceCodeEnd":73,"githubUrl":"https://github.com/decolua/9router/blob/90b52e06ffd666b7929554211474d01588f6b1f8/src/lib/oauth/services/gemini.js#L37-L73","documentation":"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\",...}).","triggerScenarios":"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.","commonSituations":"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.","solutions":["Parse the message body's 'error' field — it names the exact OAuth failure (invalid_grant, redirect_uri_mismatch, invalid_client).","For invalid_grant: restart the whole connect() flow from the browser authorization step; codes are single-use and short-lived.","For redirect_uri_mismatch: ensure the identical `http://localhost:${port}/callback` used in buildAuthUrl is passed to exchangeCode.","For invalid_client: check GEMINI_CONFIG clientId/clientSecret in src/lib/oauth/constants/oauth.js against the current Gemini CLI OAuth client values.","Retry once on 5xx only; 4xx responses will not succeed on retry without a fresh code."],"exampleFix":"// before\nif (!response.ok) {\n  const error = await response.text();\n  throw new Error(`Token exchange failed: ${error}`);\n}\n// after\nif (!response.ok) {\n  const error = await response.text();\n  let hint = \"\";\n  try { hint = JSON.parse(error).error || \"\"; } catch {}\n  if (hint === \"invalid_grant\") hint = \" (code expired or already used — restart connect())\";\n  if (hint === \"redirect_uri_mismatch\") hint = \" (redirect_uri differs from authorize step)\";\n  throw new Error(`Token exchange failed: ${error}${hint}`);\n}","handlingStrategy":"try-catch","validationCode":"// pre-checks before exchange\nif (!code) throw new Error(\"No authorization code to exchange\");\nif (!redirectUri.startsWith(\"http://localhost:\")) throw new Error(\"redirect_uri must match the authorize-step localhost URI\");","typeGuard":"function isOAuthErrorBody(text) {\n  try { const b = JSON.parse(text); return typeof b.error === \"string\"; } catch { return false; }\n}","tryCatchPattern":"try {\n  const tokens = await geminiService.exchangeCode(code, redirectUri);\n} catch (err) {\n  const m = /^Token exchange failed: (.*)/.exec(err.message);\n  if (m) {\n    const reason = (() => { try { return JSON.parse(m[1]).error; } catch { return m[1]; } })();\n    if (reason === \"invalid_grant\") console.error(\"Code expired/used — restart connect() from the browser step.\");\n    else if (reason === \"redirect_uri_mismatch\") console.error(\"Pass the exact same redirect_uri used in the authorize URL.\");\n    else if (reason === \"invalid_client\") console.error(\"Bundled client credentials are stale — update GEMINI_CONFIG.\");\n    else console.error(\"Token exchange failed:\", reason);\n  } else throw err;\n}","preventionTips":["Treat authorization codes as single-use, ~10-minute-lifetime secrets — exchange immediately.","Never restart the local callback server between authorize and exchange (port changes break redirect_uri).","Pin/verify GEMINI_CONFIG client credentials against the current Gemini CLI release.","Only retry on 5xx; 4xx responses need a fresh authorization code."],"tags":["oauth","token-exchange","gemini","google","http-4xx"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}