Files
coder/coderd/x/chatd/chaterror/message.go
T
Ethan 21c2acbad5 fix: refine chat retry status UX (#23651)
Follow-up to #23282. The retry and terminal error callouts had a few UX
oddities:

- Auto-retrying states reused backend error text that said "Please try
again" even while the UI was already retrying on behalf of the user.
- Terminal error states also said "Please try again" with no action the
user could take.
- `startup_timeout` had no specific title or retry copy — it fell
through to the generic "Retrying request" heading.
- The kind pill showed raw enum values like `startup_timeout` and
`rate_limit`.
- Terminal error metadata showed a "Retryable" / "Not retryable" label
that does not help users.
- A separate "Provider anthropic" metadata row duplicated information
already present in the message body.
- The `usage-limit` error kind used a hyphen while every backend kind
uses underscores.

Changes:

**Backend (`chaterror/message.go`)**

- Split message generation into `terminalMessage()` and
`retryMessage()`, replacing the old `userFacingMessage()`.
- Terminal messages include HTTP status codes and actionable guidance
(e.g. "Check the API key, permissions, and billing settings.").
- Retry messages are clean factual statements without status codes or
remediation, suitable for the retry countdown UI (e.g. "Anthropic is
temporarily overloaded.").
- Removed "Please try again" / "Please try again later" from all paths.
- `StreamRetryPayload` calls `retryMessage()` instead of forwarding
`classified.Message`.

**Frontend**

- Removed the parallel frontend message-generation system:
`getRetryMessage()`, `getProviderDisplayName()`,
`getRetryProviderSubject()`, and the `PROVIDER_DISPLAY_NAMES` map are
all deleted from `chatStatusHelpers.ts`.
- `liveStatusModel.ts` passes `retryState.error` through directly — the
backend owns the copy.
- Added specific title and retry copy for `startup_timeout`, and
extended the title mapping to cover `auth` and `config`.
- Kind pills now show humanized labels ("Startup timeout", "Rate limit",
etc.) instead of raw enum strings.
- Removed the redundant "Provider anthropic" metadata row.
- Removed the terminal "Retryable" / "Not retryable" badge.
- Normalized `"usage-limit"` → `"usage_limit"` and added it to
`ChatProviderFailureKind` so all error kinds follow the same underscore
convention and live in one enum.

Refs #23282.
2026-03-26 17:37:27 +11:00

158 lines
4.0 KiB
Go

package chaterror
import (
"fmt"
"strings"
)
// terminalMessage produces the user-facing error description shown
// when retries are exhausted. It includes HTTP status codes and
// actionable remediation guidance.
func terminalMessage(classified ClassifiedError) string {
subject := providerSubject(classified.Provider)
switch classified.Kind {
case KindOverloaded:
if classified.StatusCode > 0 {
return fmt.Sprintf(
"%s is temporarily overloaded (HTTP %d).",
subject, classified.StatusCode,
)
}
return fmt.Sprintf("%s is temporarily overloaded.", subject)
case KindRateLimit:
if classified.StatusCode > 0 {
return fmt.Sprintf(
"%s is rate limiting requests (HTTP %d).",
subject, classified.StatusCode,
)
}
return fmt.Sprintf("%s is rate limiting requests.", subject)
case KindTimeout:
if classified.StatusCode > 0 {
return fmt.Sprintf(
"%s is temporarily unavailable (HTTP %d).",
subject, classified.StatusCode,
)
}
if !classified.Retryable {
return "The request timed out before it completed."
}
return fmt.Sprintf("%s is temporarily unavailable.", subject)
case KindStartupTimeout:
return fmt.Sprintf(
"%s did not start responding in time.", subject,
)
case KindAuth:
displayName := providerDisplayName(classified.Provider)
if displayName == "" {
displayName = "the AI provider"
}
return fmt.Sprintf(
"Authentication with %s failed."+
" Check the API key, permissions, and billing settings.",
displayName,
)
case KindConfig:
return fmt.Sprintf(
"%s rejected the model configuration."+
" Check the selected model and provider settings.",
subject,
)
default:
if classified.StatusCode > 0 {
return fmt.Sprintf(
"%s returned an unexpected error (HTTP %d).",
subject, classified.StatusCode,
)
}
if !classified.Retryable {
return "The chat request failed unexpectedly."
}
return fmt.Sprintf("%s returned an unexpected error.", subject)
}
}
// retryMessage produces a clean factual description suitable for
// display alongside the retry countdown UI. It omits HTTP status
// codes (surfaced separately in the payload) and remediation
// guidance (not actionable while auto-retrying).
func retryMessage(classified ClassifiedError) string {
subject := providerSubject(classified.Provider)
switch classified.Kind {
case KindOverloaded:
return fmt.Sprintf("%s is temporarily overloaded.", subject)
case KindRateLimit:
return fmt.Sprintf("%s is rate limiting requests.", subject)
case KindTimeout:
return fmt.Sprintf("%s is temporarily unavailable.", subject)
case KindStartupTimeout:
return fmt.Sprintf(
"%s did not start responding in time.", subject,
)
case KindAuth:
displayName := providerDisplayName(classified.Provider)
if displayName == "" {
displayName = "the AI provider"
}
return fmt.Sprintf(
"Authentication with %s failed.", displayName,
)
case KindConfig:
return fmt.Sprintf(
"%s rejected the model configuration.", subject,
)
default:
return fmt.Sprintf(
"%s returned an unexpected error.", subject,
)
}
}
func providerSubject(provider string) string {
if displayName := providerDisplayName(provider); displayName != "" {
return displayName
}
return "The AI provider"
}
func providerDisplayName(provider string) string {
switch normalizeProvider(provider) {
case "anthropic":
return "Anthropic"
case "azure":
return "Azure OpenAI"
case "bedrock":
return "AWS Bedrock"
case "google":
return "Google"
case "openai":
return "OpenAI"
case "openai-compat":
return "OpenAI Compatible"
case "openrouter":
return "OpenRouter"
case "vercel":
return "Vercel AI Gateway"
default:
return ""
}
}
func normalizeProvider(provider string) string {
normalized := strings.ToLower(strings.TrimSpace(provider))
switch normalized {
case "azure openai", "azure-openai":
return "azure"
case "openai compat", "openai compatible", "openai_compat":
return "openai-compat"
default:
return normalized
}
}