tinyhumansai/openhuman · error
OpenRouter key exchange failed
Error message
OpenRouter key exchange failed (${response.status}). What it means
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.
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)
Example fix
// before
const key = await exchangeCodeForKey(code, verifier, fetch);
// after
if (err.message.includes('(400)')) { return startFlowFromScratch(); } // code burned — need a new one
await backoffRetry(() => exchangeCodeForKey(code, verifier, fetch), 3); // only for 429/5xx Defensive patterns
Strategy: retry
Validate before calling
// Classify before deciding to retry:
function isRetryableExchangeFailure(err: unknown): boolean {
const m = err instanceof Error ? err.message : '';
return /\(429\)|\(5\d{2}\)/.test(m); // rate limit / server error only
} Try / catch
try {
return await exchangeCodeForKey(code, verifier, fetchImpl);
} catch (err) {
if (isRetryableExchangeFailure(err)) {
await backoff(500);
return await exchangeCodeForKey(code, verifier, fetchImpl);
}
if (err instanceof Error && err.message.includes('(400)')) {
throw new Error('authorization code expired/used — restart the OAuth flow');
}
throw err;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- OpenRouter key exchange succeeded but no API key was…
- OpenRouter OAuth callback state did not match the request.
- OpenRouter OAuth did not return an authorization code.
- OpenRouter OAuth returned an invalid callback URL.
- composio.authorize: entries must be strings
AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17).
Data as JSON: /api/errors/e2c3a604447b8d63.
Report an issue: GitHub.
Appendix: source
Thrown at app/src/utils/openrouterOAuth.ts:90
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }),
});
let body: OpenRouterExchangeResponse | null = null;
try {
body = (await response.json()) as OpenRouterExchangeResponse;
} catch {
body = null;
}
if (!response.ok) {
const detail =
typeof body?.error === 'string'
? body.error
: body?.error && typeof body.error === 'object'
? body.error.message
: null;
throw new Error(detail || `OpenRouter key exchange failed (${response.status}).`);
}
if (!body?.key || typeof body.key !== 'string') {
throw new Error('OpenRouter key exchange succeeded but no API key was returned.');
}
return body.key;
}
function toOpenRouterCallbackUrl(redirectUri: string): string {
let parsed: URL;
try {
parsed = new URL(redirectUri);
} catch {
throw new Error('OpenRouter OAuth listener returned an invalid redirect URL.');
}
// Preserve the port the loopback listener actually bound to (carried inView on GitHub (pinned to 7491200858)