plandex-ai/plandex · error

token exchange failed - status: %d, body: %s

Error message

token exchange failed - status: %d, body: %s

What it means

This is the standard non-200 response error from the Claude Max OAuth token exchange. When the token endpoint returns any status other than 200, exchangeCode reads the response body and returns it verbatim inside this error so the caller can see the server's rejection reason.

Source

Thrown at app/cli/lib/claude_max.go:258

	req, err := http.NewRequest("POST", claudeMaxTokenUrl, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("token exchange failed - error creating request: %s", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("anthropic-beta", shared.AnthropicClaudeMaxBetaHeader)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, fmt.Errorf("token exchange failed - error reading body: %s", err)
		}
		return nil, fmt.Errorf("token exchange failed - status: %d, body: %s", resp.StatusCode, b)
	}
	var t types.OauthResponse
	if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
		return nil, err
	}
	return &t, nil
}

func genCodeVerifier() (string, error) {
	buf := make([]byte, 32)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(buf), nil
}

func sha256Base64(verifier string) string {
	sum := sha256.Sum256([]byte(verifier))

View on GitHub (pinned to e2d772072e)

Solutions

  1. Read the returned status and body — 400/401 with 'invalid_grant' means restart the whole OAuth flow to get a fresh code.
  2. Verify redirect_uri and client_id exactly match the values used in the authorization request.
  3. Do not reuse authorization codes; exchange the code immediately after receiving it.
  4. On 429/5xx, retry after a delay.

Example fix

// before
return nil, fmt.Errorf("token exchange failed - status: %d, body: %s", resp.StatusCode, b)
// after
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
	return nil, retryable{fmt.Errorf("token exchange failed - status: %d, body: %s", resp.StatusCode, b)}
}
return nil, fmt.Errorf("token exchange failed - status: %d, body: %s", resp.StatusCode, b)
Defensive patterns

Strategy: retry

Validate before calling

// exchange codes immediately and only once
if time.Since(codeReceivedAt) > 5*time.Minute {
	// code expired: re-run authorization instead of exchanging
}
// ensure redirect_uri matches the authorize request exactly
if redirectURI != authorizeRedirectURI { /* abort: guaranteed 400 */ }

Type guard

func isTokenRejection(err error) bool { return strings.Contains(err.Error(), "status: 4") }

Try / catch

tok, err := exchangeCode(ctx, code, verifier)
if err != nil {
	var status int
	if _, serr := fmt.Sscanf(err.Error(), "token exchange failed - status: %d", &status); serr == nil && (status == 429 || status >= 500) {
		// retry with backoff
	} else if status >= 400 && status < 500 {
		// re-run full OAuth flow for a fresh code
	}
}

Prevention

When it happens

Trigger: POST to claudeMaxTokenUrl completes but returns a non-200 status — invalid/expired authorization code (400/401), bad client_id or redirect_uri mismatch, rate limiting (429), or server-side errors (5xx).

Common situations: The authorization code was already redeemed or expired (codes are single-use and short-lived); redirect_uri does not exactly match the one used in the authorize step; wrong or outdated client_id; Anthropic API outage or rate limiting.

Related errors


AI-assisted analysis of plandex-ai/plandex@e2d772072e (2026-09-05). Data as JSON: /api/errors/ecfe871d70cde7b2. Report an issue: GitHub.