{"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":"exception","errorClass":"Error","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/a221052e0df5b1f7598fceba7329fd1af95d6699/app/src/utils/openrouterOAuth.ts#L72-L108","documentation":"The PKCE code-to-key exchange POST to OPENROUTER_TOKEN_URL returned non-OK and the body carried no usable error string, so the HTTP status is surfaced as the only diagnostic. 4xx usually means a bad, expired, or already-consumed code or a verifier mismatch; 5xx is an OpenRouter-side incident.","triggerScenarios":"Exchanging a code past its short TTL or one already consumed by an earlier attempt; code_verifier differing from the SHA-256 code_challenge sent at authorize time; OpenRouter returning 5xx.","commonSituations":"Debugger pause or slow machine letting the code expire before the exchange; retry logic re-posting the same code after a first failure; replayed captured codes in tests.","solutions":["Restart the whole flow (new verifier + new authorize request) — a single-use code cannot be re-exchanged","Verify the code_verifier is the exact string hashed for the challenge","On 5xx, back off and retry later; check OpenRouter status","Log the response body even when its shape is unexpected — it may name the reason"],"exampleFix":"// before\nconst key = await exchangeCodeForKey(code, verifier, fetch);\n\n// after — 4xx restarts the flow, 5xx retries the exchange with backoff\ntry {\n  key = await exchangeCodeForKey(code, verifier, fetch);\n} catch (e) {\n  if (/\\(4\\d\\d\\)/.test(e.message)) return startOAuthFlow();\n  if (/\\(5\\d\\d\\)/.test(e.message)) { await backoff(); key = await exchangeCodeForKey(code, verifier, fetch); }\n  else throw e;\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"Inspect the embedded status: 4xx → abandon the exchange and restart the full OAuth flow (new verifier, new code); 5xx → retry the same exchange with exponential backoff, capped at 2-3 attempts; surface the status and any logged body otherwise.","preventionTips":["Exchange the code immediately after capturing the callback — codes are short-lived and single-use","Never re-post a consumed code across retries; restart the flow instead","Ensure code_verifier is the exact string used to build the SHA-256 challenge"],"tags":["oauth","openrouter","pkce","http","token-exchange"],"backgroundTag":null,"analyzedSha":"a221052e0df5b1f7598fceba7329fd1af95d6699","analyzedAt":"2026-08-16T12:47:06.542Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}