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
- Read the error field in the embedded body — it maps directly to the fix (invalid_grant → re-login; invalid_client → fix credentials)
- On invalid_grant: the refresh token is dead; prompt the user to re-authenticate (LoginDeviceCode or browser flow) and replace the stored credential
- Verify cfg.ClientID and cfg.ClientSecret match the provider app registration and are consistent with the token originally issued
- Confirm tokenURL: cfg.TokenURL silently overrides Issuer+'/oauth/token' — a stale value hits the wrong endpoint
- 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
- Parse the error field from embedded bodies instead of string-matching blindly
- Never retry invalid_grant — go straight to re-authentication
- Keep ClientID/ClientSecret consistent across environments
- Log status codes with bodies; honor Retry-After for 429
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
- token exchange failed: %s
- no refresh token available
- refreshing token: %w
- reading token refresh response: %w
- error fetching models: %w
AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15).
Data as JSON: /api/errors/3e266793001e945a.
Report an issue: GitHub.