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

  1. For 400: restart the entire flow (new verifier + code) — codes are single-use, retrying the exchange cannot succeed
  2. For 429/5xx: retry the POST with backoff after a short delay
  3. Log body?.error?.message when present; enable it before falling back to the generic text
  4. 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

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


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 in

View on GitHub (pinned to 7491200858)