{"record":{"id":"e2c3a604447b8d63","repo":"tinyhumansai/openhuman","slug":"openrouter-key-exchange-failed-response-status","errorCode":null,"errorMessage":"OpenRouter key exchange failed (${response.status}).","messagePattern":"OpenRouter key exchange failed \\((.+?)\\)\\.","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/src/utils/openrouterOAuth.ts","lineNumber":90,"sourceCode":"    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }),\n  });\n\n  let body: OpenRouterExchangeResponse | null = null;\n  try {\n    body = (await response.json()) as OpenRouterExchangeResponse;\n  } catch {\n    body = null;\n  }\n\n  if (!response.ok) {\n    const detail =\n      typeof body?.error === 'string'\n        ? body.error\n        : body?.error && typeof body.error === 'object'\n          ? body.error.message\n          : null;\n    throw new Error(detail || `OpenRouter key exchange failed (${response.status}).`);\n  }\n\n  if (!body?.key || typeof body.key !== 'string') {\n    throw new Error('OpenRouter key exchange succeeded but no API key was returned.');\n  }\n\n  return body.key;\n}\n\nfunction toOpenRouterCallbackUrl(redirectUri: string): string {\n  let parsed: URL;\n  try {\n    parsed = new URL(redirectUri);\n  } catch {\n    throw new Error('OpenRouter OAuth listener returned an invalid redirect URL.');\n  }\n\n  // Preserve the port the loopback listener actually bound to (carried in","sourceCodeStart":72,"sourceCodeEnd":108,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/749120085864ce16e0f273c7b86fac7740b39c5b/app/src/utils/openrouterOAuth.ts#L72-L108","documentation":"exchangeCodeForKey() POSTs the authorization code + PKCE verifier to https://openrouter.ai/api/v1/auth/keys; on a non-ok response it tries to extract body.error (string or {message}) and falls back to this generic status message when the body carried no usable detail. The status code is the real signal: 400 means invalid/expired code or PKCE mismatch, 429 rate limit, 5xx provider trouble.","triggerScenarios":"Authorization code already used or expired (they are single-use and short-lived); PKCE verifier/code_challenge mismatch (verifier regenerated between steps); clock/network middleboxes corrupting the body; OpenRouter rate-limiting or being down.","commonSituations":"App slept between authorize and exchange; a retry re-sent the same code; the verifier was not persisted across the callback boundary; corporate proxy stripping the JSON body so the detail extraction fails too.","solutions":["For 400: restart the entire flow (new verifier + code) — codes are single-use, retrying the exchange cannot succeed","For 429/5xx: retry the POST with backoff after a short delay","Log body?.error?.message when present; enable it before falling back to the generic text","Make sure the same verifier instance that generated the code_challenge is used in the exchange (persist it across the redirect)"],"exampleFix":"// before\nconst key = await exchangeCodeForKey(code, verifier, fetch);\n// after\nif (err.message.includes('(400)')) { return startFlowFromScratch(); } // code burned — need a new one\nawait backoffRetry(() => exchangeCodeForKey(code, verifier, fetch), 3); // only for 429/5xx","handlingStrategy":"retry","validationCode":"// Classify before deciding to retry:\nfunction isRetryableExchangeFailure(err: unknown): boolean {\n  const m = err instanceof Error ? err.message : '';\n  return /\\(429\\)|\\(5\\d{2}\\)/.test(m); // rate limit / server error only\n}","typeGuard":null,"tryCatchPattern":"try {\n  return await exchangeCodeForKey(code, verifier, fetchImpl);\n} catch (err) {\n  if (isRetryableExchangeFailure(err)) {\n    await backoff(500);\n    return await exchangeCodeForKey(code, verifier, fetchImpl);\n  }\n  if (err instanceof Error && err.message.includes('(400)')) {\n    throw new Error('authorization code expired/used — restart the OAuth flow');\n  }\n  throw err;\n}","preventionTips":["Persist the PKCE verifier across the redirect; regenerating it guarantees a 400","Exchange the code immediately on callback — codes are single-use and short-lived","Never auto-retry a 400; surface body.error.message when present for the real reason"],"tags":["oauth","openrouter","pkce","token-exchange","api-key"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"749120085864ce16e0f273c7b86fac7740b39c5b","analyzedAt":"2026-08-17T21:21:45.363Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}