From 2598940283fc3d3595eb939b369089d91a51ce48 Mon Sep 17 00:00:00 2001 From: jianyx1 <166109249+jianyx1@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:42:29 +0800 Subject: [PATCH] fix(frontend): make chat stream server errors actionable (#8041) * fix(frontend): make chat stream server errors actionable * fix(frontend): log chat stream server errors --------- Co-authored-by: Willem Jiang --- frontend/src/core/api/api-client.ts | 2 +- frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 2 + frontend/src/core/threads/hooks.ts | 52 +++++++++++--------- frontend/tests/e2e/chat-stream-error.spec.ts | 50 +++++++++++++++++++ 6 files changed, 86 insertions(+), 23 deletions(-) create mode 100644 frontend/tests/e2e/chat-stream-error.spec.ts diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts index b86280ed27..ea1502ae0b 100644 --- a/frontend/src/core/api/api-client.ts +++ b/frontend/src/core/api/api-client.ts @@ -59,7 +59,7 @@ function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit { // // ``error``/``timeout`` are terminal too, so a reload within the ~60s // bridge-reap window no longer replays the buffered error event through -// ``onError`` — the transient error toast (``getStreamErrorMessage``) is +// ``onError`` — the transient error toast (``getStreamErrorToastMessage``) is // dropped. The persisted error state still loads from the checkpoint via // ``useThreadHistory``, so only the toast is lost; that is intentional, since // surfacing a stale error toast on every reload is noise rather than signal. diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index cac94e292b..390c8df289 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1637,6 +1637,8 @@ export const enUS: Translations = { branchFailed: "Failed to branch conversation.", streamReplayGap: "Some live updates expired. The conversation was restored from saved state.", + streamServerError: (status) => + `The service is temporarily unavailable (HTTP ${status}). Please try again.`, outlineLabel: "Conversation outline", outlineAttachmentFallback: "Image or file message", }, diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 99f5cfe088..cd8d3188af 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -1329,6 +1329,7 @@ export interface Translations { branchCreated: string; branchFailed: string; streamReplayGap: string; + streamServerError: (status: number) => string; outlineLabel: string; outlineAttachmentFallback: string; }; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 95e6cc8879..8076bfd498 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1538,6 +1538,8 @@ export const zhCN: Translations = { branchCreated: "已创建分叉对话", branchFailed: "创建分叉对话失败。", streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。", + streamServerError: (status) => + `服务暂时不可用(HTTP ${status}),请稍后重试。`, outlineLabel: "对话章节", outlineAttachmentFallback: "图片或文件消息", }, diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index d4faf4c09f..764a890804 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -30,6 +30,7 @@ import { getBackendBaseURL } from "../config"; import { useRunInputsEnabled } from "../features"; import { useI18n } from "../i18n/hooks"; import type { Locale } from "../i18n/locale"; +import type { Translations } from "../i18n/locales"; import { extractHitlApprovalRequests, type HitlApprovalRequest, @@ -2054,6 +2055,30 @@ function getHttpStatus(error: unknown): number | undefined { return undefined; } +function getStreamErrorToastMessage( + error: unknown, + t: Translations, + showApprovalErrors = false, +): string { + const status = getHttpStatus(error); + if (status !== undefined && status >= 500 && status < 600) { + console.warn("Chat stream request failed:", getStreamErrorMessage(error)); + return t.conversation.streamServerError(status); + } + + const message = getStreamErrorMessage(error); + if (showApprovalErrors) { + const errorText = `${JSON.stringify(error) ?? ""} ${message}`; + if (errorText.includes("hitl_approval_disabled")) { + return t.humanInput.approval.featureDisabled; + } + if (errorText.includes("no_pending_approval")) { + return t.humanInput.approval.noPending; + } + } + return message; +} + function isThreadMissingError(error: unknown): boolean { const status = getHttpStatus(error); // Treat 403 like 404 here to avoid disclosing whether an inaccessible thread @@ -2528,16 +2553,7 @@ export function useThreadStream({ pendingPreparedReplayRef.current = null; setPendingSupersededRunIds(new Set()); setPendingSupersededMessageIds(new Set()); - const streamErrorText = `${JSON.stringify(error) ?? ""} ${getStreamErrorMessage( - error, - )}`; - toast.error( - streamErrorText.includes("hitl_approval_disabled") - ? t.humanInput.approval.featureDisabled - : streamErrorText.includes("no_pending_approval") - ? t.humanInput.approval.noPending - : getStreamErrorMessage(error), - ); + toast.error(getStreamErrorToastMessage(error, t, true)); pendingUsageBaselineMessageIdsRef.current = new Set( messagesRef.current .map(messageIdentity) @@ -3281,7 +3297,7 @@ export function useThreadStream({ }; toast.error(t.humanInput.approval.submitTimeout); void stopThread().catch((error: unknown) => { - toast.error(getStreamErrorMessage(error)); + toast.error(getStreamErrorToastMessage(error, t)); }); } }, HITL_APPROVAL_ACCEPTANCE_TIMEOUT_MS); @@ -3326,16 +3342,7 @@ export function useThreadStream({ if (pendingApprovalSubmissionRef.current === submission) { settlePendingApproval(false); } - const serialized = `${JSON.stringify(error) ?? ""} ${getStreamErrorMessage( - error, - )}`; - toast.error( - serialized.includes("hitl_approval_disabled") - ? t.humanInput.approval.featureDisabled - : serialized.includes("no_pending_approval") - ? t.humanInput.approval.noPending - : getStreamErrorMessage(error), - ); + toast.error(getStreamErrorToastMessage(error, t, true)); return false; } finally { sendInFlightRef.current = false; @@ -3482,7 +3489,7 @@ export function useThreadStream({ removeSetItems(current, preparedSupersededMessageIds), ); } - toast.error(getStreamErrorMessage(error)); + toast.error(getStreamErrorToastMessage(error, t)); return false; } finally { sendInFlightRef.current = false; @@ -3494,6 +3501,7 @@ export function useThreadStream({ humanMessageCount, persistedMessages, queryClient, + t, thread, visibleHistory, ], diff --git a/frontend/tests/e2e/chat-stream-error.spec.ts b/frontend/tests/e2e/chat-stream-error.spec.ts new file mode 100644 index 0000000000..f29b56a82a --- /dev/null +++ b/frontend/tests/e2e/chat-stream-error.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +for (const { status, body, message } of [ + { + status: 502, + body: '{"detail":"upstream failed"}', + message: + "The service is temporarily unavailable (HTTP 502). Please try again.", + }, + { + status: 409, + body: '{"detail":"Another run is active"}', + message: 'HTTP 409: {"detail":"Another run is active"}', + }, +]) { + test(`shows the expected chat error for HTTP ${status}`, async ({ page }) => { + const streamWarnings: string[] = []; + page.on("console", (entry) => { + if ( + entry.type() === "warning" && + entry.text().startsWith("Chat stream request failed:") + ) { + streamWarnings.push(entry.text()); + } + }); + + mockLangGraphAPI(page); + await page.route( + /\/api\/langgraph\/(?:threads\/[^/]+\/)?runs\/stream$/, + (route) => + route.fulfill({ status, contentType: "application/json", body }), + ); + + await page.goto("/workspace/chats/new"); + const composer = page.getByRole("textbox", { + name: /how can i assist you/i, + }); + await composer.fill("Send a message"); + await composer.press("Enter"); + + await expect(page.locator("[data-sonner-toast]").last()).toHaveText( + message, + ); + expect(streamWarnings).toEqual( + status === 502 ? [`Chat stream request failed: HTTP 502: ${body}`] : [], + ); + }); +}