plandex-ai/plandex · error

refresh failed - status %d: %s

Error message

refresh failed - status %d: %s

What it means

refreshCreds reports the HTTP status returned by the Claude Max OAuth token endpoint when it is anything other than 200 OK, including the first 64KB of the response body. The wrapped status and body are the server's own diagnosis — e.g. invalid_grant for an expired/revoked refresh token, or 401/403/5xx for server-side issues.

Source

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

	req, err := http.NewRequest("POST", claudeMaxTokenUrl, bytes.NewReader(body))
	if err != nil {
		return nil, 0, fmt.Errorf("refresh failed - create request: %w", 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, 0, fmt.Errorf("refresh failed - http: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		b, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, 0, fmt.Errorf("refresh failed - read body: %w", err)
		}
		return nil, resp.StatusCode, fmt.Errorf("refresh failed - status %d: %s", resp.StatusCode, b)
	}

	var r types.OauthResponse
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return nil, 0, fmt.Errorf("refresh failed - decode: %w", err)
	}

	newCreds := &types.OauthCreds{
		OauthResponse: r,
		ExpiresAt:     time.Now().Add(time.Duration(r.ExpiresIn) * time.Second),
	}

	// persist updated creds
	accountCreds.ClaudeMax = newCreds
	if err := SetAccountCredentials(accountCreds); err != nil {
		return nil, 0, fmt.Errorf("refresh failed - save: %w", err)
	}

View on GitHub (pinned to e2d772072e)

Solutions

  1. Read the status and body in the error: 401/400 with invalid_grant means the refresh token is dead — re-run the OAuth login flow to obtain fresh credentials.
  2. Delete the stale stored credentials (SetAccountCredentials path) so the app falls back to interactive login instead of retrying a dead token.
  3. For 5xx, wait and retry with backoff; it is a server-side incident.
  4. For 429, respect the rate limit and back off before the next refresh.
  5. Verify the system clock is accurate (large skew can invalidate token requests).

Example fix

// before
creds, _, err := refreshCreds(accountCreds)
if err != nil { return err }
// after
creds, status, err := refreshCreds(accountCreds)
if err != nil {
    if status == http.StatusUnauthorized || status == http.StatusBadRequest {
        // refresh token expired/revoked: force re-login
        _ = ClearAccountCredentials()
        return startOAuthLoginFlow(ctx)
    }
    return retry.WithBackoff(func() error { _, _, err = refreshCreds(accountCreds); return err }, 3)
}
Defensive patterns

Strategy: fallback

Validate before calling

// proactively refresh before expiry so expired refresh tokens are caught early
if creds.ExpiresAt.Before(time.Now().Add(5 * time.Minute)) {
    return refreshClaudeMaxCredsIfNeeded()
}

Type guard

func isAuthRejection(status int) bool {
    return status == http.StatusUnauthorized || status == http.StatusBadRequest
}

Try / catch

creds, status, err := refreshCreds(acct)
if err != nil {
    if isAuthRejection(status) {
        return reAuthInteractive(ctx) // refresh token dead: full login
    }
    return retryWithBackoff(func() error { _, _, err = refreshCreds(acct); return err }, 3)
}

Prevention

When it happens

Trigger: The POST to claudeMaxTokenUrl completes but resp.StatusCode != http.StatusOK — expired or revoked refresh token, bad client credentials, server 5xx, or rate limiting.

Common situations: Long-lived sessions where the refresh token expired or was revoked (user logged out elsewhere / password change), API incidents returning 5xx, rate limits after many concurrent refreshes, or clock skew invalidating token validity windows.

Related errors


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