sipeed/picoclaw · error

token refresh failed: %s

Error message

token refresh failed: %s

What it means

RefreshAccessToken (pkg/auth/oauth.go:469) received a non-200 from the token endpoint and embeds the raw body. The body is an OAuth error JSON (e.g. {"error":"invalid_grant"}) but the HTTP status code is omitted from the message. invalid_grant means the refresh token is expired, revoked, or issued to a different client; invalid_client means wrong client_id/secret.

Source

Thrown at pkg/auth/oauth.go:469

	}

	tokenURL := cfg.Issuer + "/oauth/token"
	if cfg.TokenURL != "" {
		tokenURL = cfg.TokenURL
	}

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

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

	refreshed, err := parseTokenResponse(body, cred.Provider)
	if err != nil {
		return nil, err
	}
	if refreshed.RefreshToken == "" {
		refreshed.RefreshToken = cred.RefreshToken
	}
	if refreshed.AccountID == "" {
		refreshed.AccountID = cred.AccountID
	}
	if cred.Email != "" && refreshed.Email == "" {
		refreshed.Email = cred.Email
	}
	if cred.ProjectID != "" && refreshed.ProjectID == "" {
		refreshed.ProjectID = cred.ProjectID
	}

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Read the error field in the embedded body — it maps directly to the fix (invalid_grant → re-login; invalid_client → fix credentials)
  2. On invalid_grant: the refresh token is dead; prompt the user to re-authenticate (LoginDeviceCode or browser flow) and replace the stored credential
  3. Verify cfg.ClientID and cfg.ClientSecret match the provider app registration and are consistent with the token originally issued
  4. Confirm tokenURL: cfg.TokenURL silently overrides Issuer+'/oauth/token' — a stale value hits the wrong endpoint
  5. Log resp.StatusCode with the body; honor Retry-After on 429

Example fix

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

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

Strategy: try-catch

Validate before calling

// Confirm the refresh setup is coherent before the call
if cred == nil || cred.RefreshToken == "" {
	return fmt.Errorf("credential lacks refresh token")
}
if cfg.ClientID == "" {
	return fmt.Errorf("client_id required for refresh")
}

Type guard

func isInvalidGrant(err error) bool {
	return err != nil && strings.Contains(err.Error(), "token refresh failed") && strings.Contains(err.Error(), "invalid_grant")
}
func isInvalidClient(err error) bool {
	return err != nil && strings.Contains(err.Error(), "token refresh failed") && strings.Contains(err.Error(), "invalid_client")
}

Try / catch

refreshed, err := auth.RefreshAccessToken(cred, cfg)
if err != nil {
	switch {
	case isInvalidGrant(err):
		// token dead: only re-login helps
		refreshed, err = reloginFlow(cfg)
	case isInvalidClient(err):
		return fmt.Errorf("client credentials misconfigured: %w", err)
	}
	if err != nil {
		return err
	}
}

Prevention

When it happens

Trigger: POST to TokenURL/Issuer+'/oauth/token' with grant_type=refresh_token returns: 400 invalid_grant (expired/revoked/mismatched token), 401 invalid_client (bad client_id or client_secret), 400 invalid_request (scope not granted), 429/5xx.

Common situations: Refresh token expired after long inactivity or user revoked app access; rotating refresh tokens where the second use of a consumed token fails; cfg.ClientID/ClientSecret changed between environments; TokenURL pointing at the wrong provider's token endpoint; password change invalidating grants.

Related errors


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