charmbracelet/crush · error

failed to refresh OAuth token for provider %s: %w

Error message

failed to refresh OAuth token for provider %s: %w

What it means

After acquiring the refresh lock, the store exchanges/refreshes the OAuth token with the provider. If the exchange call fails (including after one retry with a refresh token that a peer session rotated), the error is wrapped as 'failed to refresh OAuth token for provider %s'. It signals the provider rejected the refresh attempt.

Source

Thrown at internal/config/store.go:734

	// Disk still holds our token (or no newer peer token exists) and we hold
	// the lock, so we are the sole exchanger. Perform the exchange.
	refreshedToken, refreshErr := s.exchange(ctx, providerID, entryToken.RefreshToken)
	if refreshErr != nil {
		// The exchange may have failed because a peer rotated the refresh
		// token in a window we did not cover. Re-check disk: adopt a usable
		// token, or retry once with the peer's newer refresh token.
		if diskToken := s.newerDiskToken(scope, providerID, entryToken); diskToken != nil {
			if !diskToken.IsExpired() {
				slog.Info("Adopting token refreshed by another session after exchange failure", "provider", providerID)
				return s.applyToken(providerConfig, diskToken, providerID)
			}
			slog.Info("Retrying exchange with refresh token rotated by another session", "provider", providerID)
			refreshedToken, refreshErr = s.exchange(ctx, providerID, diskToken.RefreshToken)
		}
	}
	if refreshErr != nil {
		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
	}

	slog.Info("Successfully refreshed OAuth token", "provider", providerID)
	if err := s.applyToken(providerConfig, refreshedToken, providerID); err != nil {
		return err
	}

	if err := s.SetConfigFields(scope, map[string]any{
		fmt.Sprintf("providers.%s.api_key", providerID): refreshedToken.AccessToken,
		fmt.Sprintf("providers.%s.oauth", providerID):   refreshedToken,
	}); err != nil {
		return fmt.Errorf("failed to persist refreshed token: %w", err)
	}
	return nil
}

// WaitForTokenChange blocks until SignalAuthComplete is called for the
// given provider or the context is cancelled. It is used by OnAuthRefresh

View on GitHub (pinned to 7944b8e522)

Solutions

  1. Re-authenticate with the provider (run the login/auth flow) to obtain a fresh refresh token.
  2. Check network connectivity and proxy settings to the provider endpoint.
  3. Ensure only one session/tooling rotates the refresh token; stop duplicate sessions sharing the config.
  4. Inspect the wrapped provider error for 4xx vs 5xx to decide between re-auth and retry.

Example fix

// before
refreshedToken, refreshErr = s.exchange(ctx, providerID, refreshToken)
// after: detect invalid_grant and force full re-auth
if refreshErr != nil && strings.Contains(refreshErr.Error(), "invalid_grant") {
    return requireReauth(providerID) // prompt user to log in again
}
Defensive patterns

Strategy: retry

Validate before calling

if !hasRefreshableOAuth(providerID) { promptLogin(providerID) }

Try / catch

if err := refresh(); err != nil {
    if isAuthError(err) { runReauthFlow(providerID) } else if isTransient(err) { backoffRetry(refresh) }
}

Prevention

When it happens

Trigger: s.exchange(ctx, providerID, refreshToken) returns an error: invalid/expired refresh token, provider API outage, network failure, or the token was rotated by another session and the retry with the disk token also failed.

Common situations: Refresh token revoked (password change, admin revocation, grant rotation by a parallel CLI); Copilot/Hyper token expiry after long offline periods; corporate proxy blocking provider endpoints.

Related errors


AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29). Data as JSON: /api/errors/90bc6d379546562a. Report an issue: GitHub.