sipeed/picoclaw · error

token exchange failed: %s

Error message

token exchange failed: %s

What it means

ExchangeCodeForTokens (pkg/auth/oauth.go:565) got a non-200 from the token endpoint and embeds the raw body (status code not included). Standard OAuth errors appear here: invalid_grant (bad/expired/reused code, wrong redirect_uri or code_verifier), invalid_client (bad client_id/secret).

Source

Thrown at pkg/auth/oauth.go:565

	// Determine provider name from config
	provider := "openai"
	if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
		provider = "google-antigravity"
	}

	resp, err := http.PostForm(tokenURL, data)
	if err != nil {
		return nil, fmt.Errorf("exchanging code for tokens: %w", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading token exchange response: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("token exchange failed: %s", string(body))
	}

	return parseTokenResponse(body, provider)
}

func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
	var tokenResp struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int    `json:"expires_in"`
		IDToken      string `json:"id_token"`
	}
	if err := json.Unmarshal(body, &tokenResp); err != nil {
		return nil, fmt.Errorf("parsing token response: %w", err)
	}

	if tokenResp.AccessToken == "" {
		return nil, fmt.Errorf("no access token in response")

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Parse the error field in the embedded body: invalid_grant → restart login; invalid_client → fix client_id/secret; mismatch params → align redirect_uri/code_verifier
  2. If a network glitch interrupted a previous exchange, assume the code is consumed and restart the flow rather than retrying blindly
  3. Ensure redirectURI passed to ExchangeCodeForTokens exactly matches the one used in the authorize request
  4. Verify cfg.ClientSecret is set if the provider requires a confidential client; confirm tokenURL is the right endpoint
  5. Include resp.StatusCode when logging to speed up triage

Example fix

// before
if resp.StatusCode != http.StatusOK {
	return nil, fmt.Errorf("token exchange failed: %s", string(body))
}

// after
if resp.StatusCode != http.StatusOK {
	return nil, fmt.Errorf("token exchange failed: status %d: %s", resp.StatusCode, string(body))
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Verify exchange parameters mirror the authorize request
if code == "" || codeVerifier == "" || redirectURI == "" {
	return fmt.Errorf("code, verifier, and redirect URI are all required")
}
if cfg.ClientSecret == "" && providerRequiresSecret(cfg) {
	return fmt.Errorf("client_secret required by this provider")
}

Type guard

func isTokenExchangeRejected(err error) bool {
	return err != nil && strings.Contains(err.Error(), "token exchange failed")
}

Try / catch

cred, err := auth.ExchangeCodeForTokens(cfg, code, verifier, redirectURI)
if err != nil && isTokenExchangeRejected(err) {
	// body embeds the OAuth error: invalid_grant means restart login
	if strings.Contains(err.Error(), "invalid_grant") {
		return reloginFlow(cfg)
	}
	return err
}

Prevention

When it happens

Trigger: POST grant_type=authorization_code returns 400 invalid_grant when the code expired (~1-10 min), was already exchanged (e.g. after a mid-body read retry), redirect_uri differs from the authorize request, or the PKCE code_verifier is wrong; 401 invalid_client on credential mismatch; 429/5xx otherwise.

Common situations: User sits on the callback too long before exchange; retrying after a lost response reuses a consumed code; redirectURI not matching the one built into the authorize URL (pollDeviceCode uses {Issuer}/deviceauth/callback); client secret required but unset; TokenURL pointing at the wrong provider.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/1e3e05b5145683bd. Report an issue: GitHub.