mastra-ai/mastra · error

Token exchange failed: ${error}

Error message

Token exchange failed: ${error}

What it means

After validating the code and state, `completeAnthropicLogin` POSTs to Anthropic's token endpoint (console.anthropic.com/v1/oauth/token) with grant_type=authorization_code. If the HTTP response is not ok, it reads the response body as text and throws 'Token exchange failed: <body>'. The embedded text is Anthropic's own error payload (e.g. invalid_grant, invalid_client) and is the key to diagnosing the failure.

Source

Thrown at mastracode/sdk/src/auth/providers/anthropic.ts:91

    // caller (and, in the shipyard server, the containing project lock)
    // indefinitely. See 2025-07-23 shipyard latency incident.
    signal: AbortSignal.timeout(15_000),
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      code,
      state,
      redirect_uri: REDIRECT_URI,
      code_verifier: verifier,
    }),
  });

  if (!tokenResponse.ok) {
    const error = await tokenResponse.text();
    throw new Error(`Token exchange failed: ${error}`);
  }

  const tokenData = (await tokenResponse.json()) as {
    access_token: string;
    refresh_token: string;
    expires_in: number;
  };

  // Calculate expiry time (current time + expires_in seconds - 5 min buffer)
  const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;

  return {
    refresh: tokenData.refresh_token,
    access: tokenData.access_token,
    expires: expiresAt,
  };
}

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Read the embedded error text: invalid_grant usually means the code was consumed or expired — restart with startAnthropicLogin() and re-authorize; do not reuse the same code.
  2. Ensure each code is exchanged exactly once; make retries generate a fresh login instead of resubmitting the same code.
  3. Verify the verifier passed to completeAnthropicLogin is the one from the startAnthropicLogin() call that generated the authorization URL (PKCE binding).
  4. For 5xx/network errors, retry with fresh backoff only after confirming the code has not been redeemed; a redeemed code will never succeed again.
  5. Check outbound network/proxy access to console.anthropic.com; the request is bounded by a 15s timeout.

Example fix

// before
// retrying the exchange after a timeout reuses the consumed code
try {
  await completeAnthropicLogin(input, verifier);
} catch {
  await completeAnthropicLogin(input, verifier); // 'Token exchange failed: invalid_grant'
}
// after
try {
  await completeAnthropicLogin(input, verifier);
} catch (e) {
  // code is single-use: always restart the flow for a fresh code
  const { url, verifier: v2 } = await startAnthropicLogin();
  showAuthUrl(url);
  const freshInput = await promptForCode();
  await completeAnthropicLogin(freshInput, v2);
}
Defensive patterns

Strategy: try-catch

Validate before calling

// pre-checks: code present and exchange not already attempted for this code
if (!code) throw new Error('No authorization code to exchange');
if (await exchangedCodes.has(code)) throw new Error('Code already redeemed — restart the login');

Type guard

function isTokenExchangeError(e: unknown): e is Error & { message: string } {
  return e instanceof Error && e.message.startsWith('Token exchange failed:');
}

Try / catch

try {
  return await completeAnthropicLogin(input, verifier);
} catch (e) {
  if (isTokenExchangeError(e)) {
    if (/invalid_grant/i.test(e.message)) {
      // code consumed or expired: single-use — restart the flow, never retry same code
      return startFreshLogin();
    }
    if (/\b5\d\d\b|timeout|aborted/i.test(e.message)) {
      await sleep(1000); // transient: safe retry only if code not yet redeemed
    }
  }
  throw e;
}

Prevention

When it happens

Trigger: The authorization code was already redeemed (invalid_grant) — e.g. a retry or two callers exchanging the same code; the code expired (Anthropic codes are short-lived); the PKCE code_verifier does not match the challenge sent at authorize time; a client_id/endpoint mismatch; or any 4xx/5xx from the token endpoint including timeouts raised by the built-in 15s AbortSignal.timeout.

Common situations: Double-exchange after a timeout retry; user waited too long between authorizing and pasting the code; copying the code from a stale browser tab started with an older verifier/challenge; Anthropic endpoint changes or temporary 5xx; proxy/firewall mangling the POST.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/100bb39310f9fd09. Report an issue: GitHub.