mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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.
This commit is contained in:
@@ -732,7 +732,7 @@ func TestSubscribeDeliversRetryEventViaPubsubOnce(t *testing.T) {
|
||||
expected := &codersdk.ChatStreamRetry{
|
||||
Attempt: 1,
|
||||
DelayMs: (1500 * time.Millisecond).Milliseconds(),
|
||||
Error: "OpenAI is rate limiting requests (HTTP 429). Please try again later.",
|
||||
Error: "OpenAI is rate limiting requests (HTTP 429).",
|
||||
Kind: chaterror.KindRateLimit,
|
||||
Provider: "openai",
|
||||
StatusCode: 429,
|
||||
@@ -773,7 +773,7 @@ func TestSubscribePrefersStructuredErrorPayloadViaPubsub(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
classified := chaterror.ClassifiedError{
|
||||
Message: "OpenAI is rate limiting requests (HTTP 429). Please try again later.",
|
||||
Message: "OpenAI is rate limiting requests (HTTP 429).",
|
||||
Kind: chaterror.KindRateLimit,
|
||||
Provider: "openai",
|
||||
Retryable: true,
|
||||
|
||||
@@ -178,7 +178,7 @@ func normalizeClassification(classified ClassifiedError) ClassifiedError {
|
||||
classified.Kind = KindGeneric
|
||||
}
|
||||
if classified.Message == "" {
|
||||
classified.Message = userFacingMessage(classified)
|
||||
classified.Message = terminalMessage(classified)
|
||||
}
|
||||
return classified
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestClassify(t *testing.T) {
|
||||
name: "AmbiguousOverloadKeepsProviderUnknown",
|
||||
err: xerrors.New("status 529 from upstream"),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The AI provider is temporarily overloaded (HTTP 529). Please try again later.",
|
||||
Message: "The AI provider is temporarily overloaded (HTTP 529).",
|
||||
Kind: chaterror.KindOverloaded,
|
||||
Provider: "",
|
||||
Retryable: true,
|
||||
@@ -33,7 +33,7 @@ func TestClassify(t *testing.T) {
|
||||
name: "ExplicitAnthropicOverload",
|
||||
err: xerrors.New("anthropic overloaded_error"),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "Anthropic is temporarily overloaded. Please try again later.",
|
||||
Message: "Anthropic is temporarily overloaded.",
|
||||
Kind: chaterror.KindOverloaded,
|
||||
Provider: "anthropic",
|
||||
Retryable: true,
|
||||
@@ -110,7 +110,7 @@ func TestClassify(t *testing.T) {
|
||||
name: "ExplicitStatus429ClassifiesAsRateLimit",
|
||||
err: xerrors.New("status 429 from upstream"),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The AI provider is rate limiting requests (HTTP 429). Please try again later.",
|
||||
Message: "The AI provider is rate limiting requests (HTTP 429).",
|
||||
Kind: chaterror.KindRateLimit,
|
||||
Provider: "",
|
||||
Retryable: true,
|
||||
@@ -132,7 +132,7 @@ func TestClassify(t *testing.T) {
|
||||
name: "ServiceUnavailableClassifiesAsRetryableTimeout",
|
||||
err: xerrors.New("service unavailable"),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The AI provider is temporarily unavailable. Please try again later.",
|
||||
Message: "The AI provider is temporarily unavailable.",
|
||||
Kind: chaterror.KindTimeout,
|
||||
Provider: "",
|
||||
Retryable: true,
|
||||
@@ -176,7 +176,7 @@ func TestClassify(t *testing.T) {
|
||||
name: "DeadlineExceededStaysNonRetryableTimeout",
|
||||
err: context.DeadlineExceeded,
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The request timed out before it completed. Please try again.",
|
||||
Message: "The request timed out before it completed.",
|
||||
Kind: chaterror.KindTimeout,
|
||||
Provider: "",
|
||||
Retryable: false,
|
||||
@@ -279,7 +279,7 @@ func TestClassify_TransportFailuresUseBroaderRetryMessage(t *testing.T) {
|
||||
require.True(t, classified.Retryable)
|
||||
require.Equal(
|
||||
t,
|
||||
"The AI provider is temporarily unavailable. Please try again later.",
|
||||
"The AI provider is temporarily unavailable.",
|
||||
classified.Message,
|
||||
)
|
||||
})
|
||||
@@ -299,7 +299,7 @@ func TestClassify_StartupTimeoutWrappedClassificationWins(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Equal(t, chaterror.ClassifiedError{
|
||||
Message: "OpenAI did not start responding in time. Please try again.",
|
||||
Message: "OpenAI did not start responding in time.",
|
||||
Kind: chaterror.KindStartupTimeout,
|
||||
Provider: "openai",
|
||||
Retryable: true,
|
||||
@@ -315,7 +315,7 @@ func TestWithProviderUsesExplicitHint(t *testing.T) {
|
||||
|
||||
enriched := classified.WithProvider("azure openai")
|
||||
require.Equal(t, chaterror.ClassifiedError{
|
||||
Message: "Azure OpenAI is rate limiting requests (HTTP 429). Please try again later.",
|
||||
Message: "Azure OpenAI is rate limiting requests (HTTP 429).",
|
||||
Kind: chaterror.KindRateLimit,
|
||||
Provider: "azure",
|
||||
Retryable: true,
|
||||
@@ -331,7 +331,7 @@ func TestWithProviderAddsProviderWhenUnknown(t *testing.T) {
|
||||
|
||||
enriched := classified.WithProvider("openai")
|
||||
require.Equal(t, chaterror.ClassifiedError{
|
||||
Message: "OpenAI is rate limiting requests (HTTP 429). Please try again later.",
|
||||
Message: "OpenAI is rate limiting requests (HTTP 429).",
|
||||
Kind: chaterror.KindRateLimit,
|
||||
Provider: "openai",
|
||||
Retryable: true,
|
||||
|
||||
@@ -5,78 +5,113 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func userFacingMessage(classified ClassifiedError) string {
|
||||
// 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:
|
||||
return optionalStatusMessage(
|
||||
subject,
|
||||
classified.StatusCode,
|
||||
"%s is temporarily overloaded (HTTP %d). Please try again later.",
|
||||
"%s is temporarily overloaded. Please try again later.",
|
||||
)
|
||||
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:
|
||||
return optionalStatusMessage(
|
||||
subject,
|
||||
classified.StatusCode,
|
||||
"%s is rate limiting requests (HTTP %d). Please try again later.",
|
||||
"%s is rate limiting requests. Please try again later.",
|
||||
)
|
||||
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). Please try again later.",
|
||||
subject,
|
||||
classified.StatusCode,
|
||||
"%s is temporarily unavailable (HTTP %d).",
|
||||
subject, classified.StatusCode,
|
||||
)
|
||||
}
|
||||
if classified.Retryable {
|
||||
return fmt.Sprintf("%s is temporarily unavailable. Please try again later.", subject)
|
||||
if !classified.Retryable {
|
||||
return "The request timed out before it completed."
|
||||
}
|
||||
return "The request timed out before it completed. Please try again."
|
||||
return fmt.Sprintf("%s is temporarily unavailable.", subject)
|
||||
|
||||
case KindStartupTimeout:
|
||||
return fmt.Sprintf("%s did not start responding in time. Please try again.", subject)
|
||||
return fmt.Sprintf(
|
||||
"%s did not start responding in time.", subject,
|
||||
)
|
||||
|
||||
case KindAuth:
|
||||
if displayName := providerDisplayName(classified.Provider); displayName != "" {
|
||||
return fmt.Sprintf(
|
||||
"Authentication with %s failed. Check the API key, permissions, and billing settings.",
|
||||
displayName,
|
||||
)
|
||||
displayName := providerDisplayName(classified.Provider)
|
||||
if displayName == "" {
|
||||
displayName = "the AI provider"
|
||||
}
|
||||
return "Authentication with the AI provider failed. Check the API key, permissions, and billing settings."
|
||||
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.",
|
||||
"%s rejected the model configuration."+
|
||||
" Check the selected model and provider settings.",
|
||||
subject,
|
||||
)
|
||||
|
||||
default:
|
||||
if classified.StatusCode > 0 {
|
||||
suffix := " Please try again."
|
||||
if classified.Retryable {
|
||||
suffix = " Please try again later."
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s returned an unexpected error (HTTP %d).%s",
|
||||
subject,
|
||||
classified.StatusCode,
|
||||
suffix,
|
||||
"%s returned an unexpected error (HTTP %d).",
|
||||
subject, classified.StatusCode,
|
||||
)
|
||||
}
|
||||
if classified.Retryable {
|
||||
return fmt.Sprintf(
|
||||
"%s returned an unexpected error. Please try again later.",
|
||||
subject,
|
||||
)
|
||||
if !classified.Retryable {
|
||||
return "The chat request failed unexpectedly."
|
||||
}
|
||||
return "The chat request failed unexpectedly. Please try again."
|
||||
return fmt.Sprintf("%s returned an unexpected error.", subject)
|
||||
}
|
||||
}
|
||||
|
||||
func optionalStatusMessage(subject string, statusCode int, withStatus string, withoutStatus string) string {
|
||||
if statusCode > 0 {
|
||||
return fmt.Sprintf(withStatus, subject, statusCode)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(withoutStatus, subject)
|
||||
}
|
||||
|
||||
func providerSubject(provider string) string {
|
||||
|
||||
@@ -30,7 +30,7 @@ func StreamRetryPayload(
|
||||
return &codersdk.ChatStreamRetry{
|
||||
Attempt: attempt,
|
||||
DelayMs: delay.Milliseconds(),
|
||||
Error: classified.Message,
|
||||
Error: retryMessage(classified),
|
||||
Kind: classified.Kind,
|
||||
Provider: classified.Provider,
|
||||
StatusCode: classified.StatusCode,
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestStreamErrorPayloadUsesNormalizedClassification(t *testing.T) {
|
||||
payload := chaterror.StreamErrorPayload(classified)
|
||||
|
||||
require.Equal(t, &codersdk.ChatStreamError{
|
||||
Message: "Azure OpenAI is rate limiting requests (HTTP 429). Please try again later.",
|
||||
Message: "Azure OpenAI is rate limiting requests (HTTP 429).",
|
||||
Kind: chaterror.KindRateLimit,
|
||||
Provider: "azure",
|
||||
Retryable: true,
|
||||
@@ -40,7 +40,7 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) {
|
||||
delay := 3 * time.Second
|
||||
startedAt := time.Now()
|
||||
payload := chaterror.StreamRetryPayload(2, delay, chaterror.ClassifiedError{
|
||||
Message: "retry me",
|
||||
Message: "OpenAI returned an unexpected error (HTTP 503).",
|
||||
Kind: chaterror.KindGeneric,
|
||||
Provider: "openai",
|
||||
Retryable: true,
|
||||
@@ -50,7 +50,9 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) {
|
||||
require.NotNil(t, payload)
|
||||
require.Equal(t, 2, payload.Attempt)
|
||||
require.Equal(t, delay.Milliseconds(), payload.DelayMs)
|
||||
require.Equal(t, "retry me", payload.Error)
|
||||
// Retry messages omit the HTTP status code; the status code is
|
||||
// surfaced separately in the payload's StatusCode field.
|
||||
require.Equal(t, "OpenAI returned an unexpected error.", payload.Error)
|
||||
require.Equal(t, chaterror.KindGeneric, payload.Kind)
|
||||
require.Equal(t, "openai", payload.Provider)
|
||||
require.Equal(t, 503, payload.StatusCode)
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestRun_OnRetryEnrichesProvider(t *testing.T) {
|
||||
require.Equal(t, 429, records[0].classified.StatusCode)
|
||||
require.Equal(
|
||||
t,
|
||||
"OpenAI is rate limiting requests (HTTP 429). Please try again later.",
|
||||
"OpenAI is rate limiting requests (HTTP 429).",
|
||||
records[0].classified.Message,
|
||||
)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) {
|
||||
require.Equal(t, "openai", retries[0].Provider)
|
||||
require.Equal(
|
||||
t,
|
||||
"OpenAI did not start responding in time. Please try again.",
|
||||
"OpenAI did not start responding in time.",
|
||||
retries[0].Message,
|
||||
)
|
||||
require.ErrorIs(t, <-attemptCause, errStartupTimeout)
|
||||
@@ -313,7 +313,7 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) {
|
||||
require.Equal(t, "openai", retries[0].Provider)
|
||||
require.Equal(
|
||||
t,
|
||||
"OpenAI did not start responding in time. Please try again.",
|
||||
"OpenAI did not start responding in time.",
|
||||
retries[0].Message,
|
||||
)
|
||||
require.ErrorIs(t, <-attemptCause, errStartupTimeout)
|
||||
@@ -475,7 +475,7 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) {
|
||||
require.Equal(t, "openai", retries[0].Provider)
|
||||
require.Equal(
|
||||
t,
|
||||
"OpenAI did not start responding in time. Please try again.",
|
||||
"OpenAI did not start responding in time.",
|
||||
retries[0].Message,
|
||||
)
|
||||
require.ErrorIs(t, <-attemptCause, errStartupTimeout)
|
||||
|
||||
@@ -247,7 +247,7 @@ const getPersistedDetailError = ({
|
||||
chatRecord: TypesGen.Chat | undefined;
|
||||
cachedError: ChatDetailError | undefined;
|
||||
}): ChatDetailError | undefined => {
|
||||
if (cachedError?.kind === "usage-limit") {
|
||||
if (cachedError?.kind === "usage_limit") {
|
||||
return cachedError;
|
||||
}
|
||||
if (chatStatus === "error") {
|
||||
@@ -595,7 +595,7 @@ const AgentDetail: FC = () => {
|
||||
isUsageLimitData(error.response.data)
|
||||
) {
|
||||
const reason: ChatDetailError = {
|
||||
kind: "usage-limit",
|
||||
kind: "usage_limit",
|
||||
message: formatUsageLimitMessage(error.response.data),
|
||||
};
|
||||
store.setStreamError(reason);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "components/Button/Button";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { getProviderStatusURL } from "./chatStatusHelpers";
|
||||
import { getKindLabel, getProviderStatusURL } from "./chatStatusHelpers";
|
||||
import type { LiveStatusModel } from "./liveStatusModel";
|
||||
|
||||
const RESPONSE_STARTUP_GRACE_MS = 15_000;
|
||||
@@ -130,9 +130,7 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => {
|
||||
: "warning";
|
||||
const hasMetadata =
|
||||
status.phase === "retrying" ||
|
||||
status.provider !== undefined ||
|
||||
(status.phase === "failed" && status.statusCode !== undefined) ||
|
||||
(status.phase === "failed" && status.retryable !== undefined);
|
||||
(status.phase === "failed" && status.statusCode !== undefined);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -156,7 +154,7 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => {
|
||||
className="h-5 px-2.5 text-[10px] font-semibold"
|
||||
type={pillType}
|
||||
>
|
||||
{status.kind}
|
||||
{getKindLabel(status.kind)}
|
||||
</Pill>
|
||||
</div>
|
||||
<AlertDescription>{status.message}</AlertDescription>
|
||||
@@ -171,13 +169,10 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => {
|
||||
{status.phase === "retrying" && (
|
||||
<span>Attempt {status.attempt}</span>
|
||||
)}
|
||||
{status.provider && <span>Provider {status.provider}</span>}
|
||||
|
||||
{status.phase === "failed" && status.statusCode !== undefined && (
|
||||
<span>HTTP {status.statusCode}</span>
|
||||
)}
|
||||
{status.phase === "failed" && status.retryable !== undefined && (
|
||||
<span>{status.retryable ? "Retryable" : "Not retryable"}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ export const UsageLimitExceeded: Story = {
|
||||
...defaultArgs,
|
||||
liveStatus: buildLiveStatus({
|
||||
persistedError: {
|
||||
kind: "usage-limit",
|
||||
kind: "usage_limit",
|
||||
message:
|
||||
"You've used $50.00 of your $50.00 spend limit. Your limit resets on July 1, 2025.",
|
||||
},
|
||||
@@ -80,7 +80,7 @@ export const TerminalOverloadedError: Story = {
|
||||
liveStatus: buildLiveStatus({
|
||||
persistedError: {
|
||||
kind: "overloaded",
|
||||
message: "Anthropic is currently overloaded. Please try again shortly.",
|
||||
message: "Anthropic is currently overloaded.",
|
||||
provider: "anthropic",
|
||||
retryable: true,
|
||||
statusCode: 529,
|
||||
@@ -92,9 +92,46 @@ export const TerminalOverloadedError: Story = {
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /service overloaded/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("overloaded")).toBeVisible();
|
||||
expect(canvas.getByText("Overloaded")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic is currently overloaded./i),
|
||||
).toBeVisible();
|
||||
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument();
|
||||
expect(canvas.getByText(/http 529/i)).toBeVisible();
|
||||
expect(canvas.getByRole("link", { name: /status/i })).toBeVisible();
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Terminal startup timeouts get a specific heading without provider metadata. */
|
||||
export const TerminalStartupTimeoutError: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
liveStatus: buildLiveStatus({
|
||||
persistedError: {
|
||||
kind: "startup_timeout",
|
||||
message: "Anthropic did not start responding in time.",
|
||||
provider: "anthropic",
|
||||
retryable: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /startup timed out/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Startup timeout")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic did not start responding in time./i),
|
||||
).toBeVisible();
|
||||
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("link", { name: /status/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export const LiveStreamTailContent = ({
|
||||
const shouldRenderStreamSection = shouldRenderStreamingSection(liveStatus);
|
||||
const terminalStatus = liveStatus.phase === "failed" ? liveStatus : null;
|
||||
const usageLimitStatus =
|
||||
terminalStatus?.kind === "usage-limit" ? terminalStatus : null;
|
||||
terminalStatus?.kind === "usage_limit" ? terminalStatus : null;
|
||||
const shouldRenderEmptyState =
|
||||
isTranscriptEmpty && liveStatus.phase === "idle";
|
||||
|
||||
|
||||
@@ -71,13 +71,13 @@ export const ReconnectingAfterDisconnect: Story = {
|
||||
await waitFor(() => {
|
||||
expect(canvasElement.textContent).toMatch(/reconnecting in \d+s/i);
|
||||
});
|
||||
expect(canvas.queryByText("generic")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("Unexpected error")).not.toBeInTheDocument();
|
||||
const thinkingMatches = canvas.getAllByText(/thinking\.\.\./i);
|
||||
expect(thinkingMatches.length).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
};
|
||||
|
||||
/** Generic retry reasons show the mux-style retry callout. */
|
||||
/** Generic retry reasons show automatic retry copy without a manual CTA. */
|
||||
export const RetryWithVisibleReason: Story = {
|
||||
args: {
|
||||
streamState: null,
|
||||
@@ -92,9 +92,13 @@ export const RetryWithVisibleReason: Story = {
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /retrying request/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText(/transient upstream failure/i)).toBeVisible();
|
||||
expect(canvas.getByText("generic")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic returned an unexpected error/i),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Unexpected error")).toBeVisible();
|
||||
expect(canvas.getByText(/attempt 1/i)).toBeVisible();
|
||||
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,7 +110,7 @@ export const RetryRateLimited: Story = {
|
||||
liveStatus: buildLiveStatus({
|
||||
retryState: buildRetryState({
|
||||
attempt: 3,
|
||||
error: "Anthropic asked us to back off briefly before retrying.",
|
||||
error: "Anthropic is rate limiting requests.",
|
||||
kind: "rate_limit",
|
||||
delayMs: 3000,
|
||||
}),
|
||||
@@ -118,13 +122,17 @@ export const RetryRateLimited: Story = {
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /rate limited/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("rate_limit")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic is rate limiting requests/i),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Rate limit")).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(canvasElement.textContent).toMatch(/retrying in \d+s/i);
|
||||
});
|
||||
expect(
|
||||
canvas.queryByRole("link", { name: /status/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -136,7 +144,7 @@ export const RetryInvalidTimestamp: Story = {
|
||||
liveStatus: buildLiveStatus({
|
||||
retryState: buildRetryState({
|
||||
attempt: 3,
|
||||
error: "Anthropic asked us to back off briefly before retrying.",
|
||||
error: "Anthropic is rate limiting requests.",
|
||||
kind: "rate_limit",
|
||||
delayMs: 3000,
|
||||
retryingAt: "not-a-date",
|
||||
@@ -149,12 +157,16 @@ export const RetryInvalidTimestamp: Story = {
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /rate limited/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("rate_limit")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic is rate limiting requests/i),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Rate limit")).toBeVisible();
|
||||
expect(canvas.getByText(/attempt 3/i)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(canvas.queryByText(/retrying in nan/i)).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/retrying in \d+s/i)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -167,7 +179,7 @@ export const RetryOverloaded: Story = {
|
||||
retryState: buildRetryState({
|
||||
kind: "overloaded",
|
||||
provider: "anthropic",
|
||||
error: "Anthropic is currently overloaded. Retrying your request.",
|
||||
error: "Anthropic is temporarily overloaded.",
|
||||
}),
|
||||
isAwaitingFirstStreamChunk: true,
|
||||
}),
|
||||
@@ -177,14 +189,18 @@ export const RetryOverloaded: Story = {
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /service overloaded/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("overloaded")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic is temporarily overloaded/i),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Overloaded")).toBeVisible();
|
||||
const statusLink = screen.getByRole("link", { name: /status/i });
|
||||
expect(statusLink).toBeVisible();
|
||||
expect(statusLink).toHaveAttribute("href", "https://status.anthropic.com");
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Timeout retries render the timeout-specific heading without a status CTA. */
|
||||
/** Timeout retries render timeout-specific copy without a status CTA. */
|
||||
export const RetryTimeout: Story = {
|
||||
args: {
|
||||
streamState: null,
|
||||
@@ -192,7 +208,7 @@ export const RetryTimeout: Story = {
|
||||
liveStatus: buildLiveStatus({
|
||||
retryState: buildRetryState({
|
||||
kind: "timeout",
|
||||
error: "The provider took too long to respond. Retrying now.",
|
||||
error: "Anthropic is temporarily unavailable.",
|
||||
}),
|
||||
isAwaitingFirstStreamChunk: true,
|
||||
}),
|
||||
@@ -200,9 +216,43 @@ export const RetryTimeout: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /request time(?:out|d out)/i }),
|
||||
canvas.getByRole("heading", { name: /request timed out/i }),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("timeout")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic is temporarily unavailable/i),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Timeout")).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByRole("link", { name: /status/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Startup timeouts explain the first-token delay before retrying. */
|
||||
export const RetryStartupTimeout: Story = {
|
||||
args: {
|
||||
streamState: null,
|
||||
streamTools: [],
|
||||
liveStatus: buildLiveStatus({
|
||||
retryState: buildRetryState({
|
||||
kind: "startup_timeout",
|
||||
error: "Anthropic did not start responding in time.",
|
||||
}),
|
||||
isAwaitingFirstStreamChunk: true,
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /startup timed out/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic did not start responding in time/i),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText("Startup timeout")).toBeVisible();
|
||||
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("link", { name: /status/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage";
|
||||
|
||||
const PROVIDER_STATUS_URLS: Record<string, string> = {
|
||||
anthropic: "https://status.anthropic.com",
|
||||
};
|
||||
|
||||
const normalizeProvider = (provider?: string): string | undefined => {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
case "azure openai":
|
||||
case "azure-openai":
|
||||
return "azure";
|
||||
case "openai compat":
|
||||
case "openai compatible":
|
||||
case "openai_compat":
|
||||
return "openai-compat";
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
};
|
||||
|
||||
const humanizeKind = (kind: string): string => {
|
||||
const words = kind
|
||||
.trim()
|
||||
.split(/[_\-\s]+/)
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) {
|
||||
return "Unexpected error";
|
||||
}
|
||||
return words
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
export const getErrorTitle = (
|
||||
kind: string,
|
||||
kind: ChatProviderFailureKind | (string & {}),
|
||||
mode: "retry" | "error",
|
||||
): string => {
|
||||
switch (kind) {
|
||||
@@ -12,18 +46,48 @@ export const getErrorTitle = (
|
||||
case "rate_limit":
|
||||
return "Rate limited";
|
||||
case "timeout":
|
||||
return "Request timeout";
|
||||
return "Request timed out";
|
||||
case "startup_timeout":
|
||||
return "Startup timed out";
|
||||
case "auth":
|
||||
return "Authentication failed";
|
||||
case "config":
|
||||
return "Configuration error";
|
||||
default:
|
||||
return mode === "retry" ? "Retrying request" : "Request failed";
|
||||
}
|
||||
};
|
||||
|
||||
export const getKindLabel = (
|
||||
kind: ChatProviderFailureKind | (string & {}),
|
||||
): string => {
|
||||
switch (kind) {
|
||||
case "generic":
|
||||
return "Unexpected error";
|
||||
case "overloaded":
|
||||
return "Overloaded";
|
||||
case "rate_limit":
|
||||
return "Rate limit";
|
||||
case "timeout":
|
||||
return "Timeout";
|
||||
case "startup_timeout":
|
||||
return "Startup timeout";
|
||||
case "auth":
|
||||
return "Authentication";
|
||||
case "config":
|
||||
return "Configuration";
|
||||
default:
|
||||
return humanizeKind(kind);
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderStatusURL = (
|
||||
kind: string,
|
||||
kind: ChatProviderFailureKind | (string & {}),
|
||||
provider?: string,
|
||||
): string | undefined => {
|
||||
if (!provider || kind !== "overloaded") {
|
||||
if (kind !== "overloaded") {
|
||||
return undefined;
|
||||
}
|
||||
return PROVIDER_STATUS_URLS[provider.toLowerCase()];
|
||||
const normalized = normalizeProvider(provider);
|
||||
return normalized ? PROVIDER_STATUS_URLS[normalized] : undefined;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ const makeStreamState = (
|
||||
|
||||
const makeRetryState = (overrides: Partial<RetryState> = {}): RetryState => ({
|
||||
attempt: 2,
|
||||
error: "Retrying request shortly.",
|
||||
error: "Anthropic returned an unexpected error.",
|
||||
kind: "generic",
|
||||
provider: "anthropic",
|
||||
delayMs: 2000,
|
||||
@@ -62,7 +62,7 @@ describe("deriveLiveStatus", () => {
|
||||
hasAccumulatedOutput: false,
|
||||
title: "Retrying request",
|
||||
kind: "generic",
|
||||
message: "Retrying request shortly.",
|
||||
message: "Anthropic returned an unexpected error.",
|
||||
attempt: 2,
|
||||
provider: "anthropic",
|
||||
delayMs: 2000,
|
||||
@@ -84,7 +84,6 @@ describe("deriveLiveStatus", () => {
|
||||
kind: "generic",
|
||||
message: "Chat processing failed.",
|
||||
provider: "anthropic",
|
||||
retryable: false,
|
||||
statusCode: 500,
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ export type LiveStatusModel =
|
||||
kind: string;
|
||||
message: string;
|
||||
provider?: string;
|
||||
retryable?: boolean;
|
||||
statusCode?: number;
|
||||
} & LiveStatusBase);
|
||||
|
||||
@@ -64,6 +63,21 @@ const toReconnectingLiveStatus = (
|
||||
...reconnectState,
|
||||
});
|
||||
|
||||
const toRetryingLiveStatus = (
|
||||
retryState: RetryState,
|
||||
options: { hasAccumulatedOutput?: boolean } = {},
|
||||
): Extract<LiveStatusModel, { phase: "retrying" }> => ({
|
||||
phase: "retrying",
|
||||
hasAccumulatedOutput: options.hasAccumulatedOutput ?? false,
|
||||
title: getErrorTitle(retryState.kind, "retry"),
|
||||
kind: retryState.kind,
|
||||
message: retryState.error,
|
||||
attempt: retryState.attempt,
|
||||
provider: retryState.provider,
|
||||
delayMs: retryState.delayMs,
|
||||
retryingAt: retryState.retryingAt,
|
||||
});
|
||||
|
||||
const toFailedLiveStatus = (
|
||||
error: ChatDetailError,
|
||||
options: { hasAccumulatedOutput?: boolean } = {},
|
||||
@@ -74,7 +88,6 @@ const toFailedLiveStatus = (
|
||||
kind: error.kind,
|
||||
message: error.message,
|
||||
provider: error.provider,
|
||||
retryable: error.retryable,
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
|
||||
@@ -89,17 +102,7 @@ export const deriveLiveStatus = ({
|
||||
const hasAccumulatedOutput = getHasAccumulatedOutput(streamState);
|
||||
|
||||
if (retryState) {
|
||||
return {
|
||||
phase: "retrying",
|
||||
hasAccumulatedOutput,
|
||||
title: getErrorTitle(retryState.kind, "retry"),
|
||||
kind: retryState.kind,
|
||||
message: retryState.error,
|
||||
attempt: retryState.attempt,
|
||||
provider: retryState.provider,
|
||||
delayMs: retryState.delayMs,
|
||||
retryingAt: retryState.retryingAt,
|
||||
};
|
||||
return toRetryingLiveStatus(retryState, { hasAccumulatedOutput });
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
|
||||
@@ -73,8 +73,7 @@ export const buildRetryState = (
|
||||
overrides: Partial<RetryState> = {},
|
||||
): RetryState => ({
|
||||
attempt: 1,
|
||||
error:
|
||||
"Anthropic is retrying your request after a transient upstream failure.",
|
||||
error: "Anthropic returned an unexpected error.",
|
||||
kind: "generic",
|
||||
provider: "anthropic",
|
||||
delayMs: 2000,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReconnectSchedule } from "utils/reconnectingWebSocket";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage";
|
||||
|
||||
export type ParsedToolCall = {
|
||||
id: string;
|
||||
@@ -66,7 +67,7 @@ export type ReconnectState = ReconnectSchedule;
|
||||
export type RetryState = {
|
||||
attempt: number;
|
||||
error: string;
|
||||
kind: string;
|
||||
kind: ChatProviderFailureKind | (string & {});
|
||||
provider?: string;
|
||||
delayMs?: number;
|
||||
retryingAt?: string;
|
||||
|
||||
@@ -206,13 +206,25 @@ export const WithError: Story = {
|
||||
<StoryAgentDetailView
|
||||
persistedError={{
|
||||
kind: "overloaded",
|
||||
message: "Anthropic is currently overloaded. Please try again shortly.",
|
||||
message: "Anthropic is currently overloaded.",
|
||||
provider: "anthropic",
|
||||
retryable: true,
|
||||
statusCode: 529,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByRole("heading", { name: /service overloaded/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(/anthropic is currently overloaded\./i),
|
||||
).toBeVisible();
|
||||
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument();
|
||||
expect(canvas.getByText(/http 529/i)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Input area appears disabled when `isInputDisabled` is true. */
|
||||
|
||||
@@ -104,10 +104,10 @@ describe("isUsageLimitData", () => {
|
||||
it("accepts a fully populated valid payload", () => {
|
||||
const error: ChatDetailError = {
|
||||
message: "Your usage limit has been reached.",
|
||||
kind: "usage-limit",
|
||||
kind: "usage_limit",
|
||||
};
|
||||
|
||||
expect(error.kind).toBe("usage-limit");
|
||||
expect(error.kind).toBe("usage_limit");
|
||||
expect(
|
||||
isUsageLimitData({
|
||||
spent_micros: 900_000,
|
||||
|
||||
@@ -10,21 +10,29 @@ interface UsageLimitData {
|
||||
resets_at?: string; // RFC3339
|
||||
}
|
||||
|
||||
/**
|
||||
* Known provider failure kinds surfaced in chat retry/error events.
|
||||
*/
|
||||
export type ChatProviderFailureKind =
|
||||
| "generic"
|
||||
| "overloaded"
|
||||
| "rate_limit"
|
||||
| "timeout"
|
||||
| "startup_timeout"
|
||||
| "auth"
|
||||
| "config"
|
||||
| "usage_limit";
|
||||
|
||||
/**
|
||||
* Typed classification for errors surfaced in the agent detail view.
|
||||
* - "usage-limit": the user hit a spending cap (409 + valid usage data).
|
||||
* - "usage_limit": the user hit a spending cap (409 + valid usage data).
|
||||
* - other kinds come from normalized stream/provider failures such as
|
||||
* "generic", "overloaded", "rate_limit", or "timeout".
|
||||
* "generic", "overloaded", "rate_limit", "timeout",
|
||||
* "startup_timeout", "auth", and "config".
|
||||
*/
|
||||
export type ChatDetailError = {
|
||||
message: string;
|
||||
kind:
|
||||
| "usage-limit"
|
||||
| "generic"
|
||||
| "overloaded"
|
||||
| "rate_limit"
|
||||
| "timeout"
|
||||
| (string & {});
|
||||
kind: ChatProviderFailureKind | (string & {});
|
||||
provider?: string;
|
||||
retryable?: boolean;
|
||||
statusCode?: number;
|
||||
|
||||
Reference in New Issue
Block a user