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 <willem.jiang@gmail.com>
This commit is contained in:
jianyx1 2026-11-09 20:42:29 +08:00 committed by GitHub
parent fa8e015023
commit 2598940283
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 86 additions and 23 deletions

View File

@ -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.

View File

@ -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",
},

View File

@ -1329,6 +1329,7 @@ export interface Translations {
branchCreated: string;
branchFailed: string;
streamReplayGap: string;
streamServerError: (status: number) => string;
outlineLabel: string;
outlineAttachmentFallback: string;
};

View File

@ -1538,6 +1538,8 @@ export const zhCN: Translations = {
branchCreated: "已创建分叉对话",
branchFailed: "创建分叉对话失败。",
streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。",
streamServerError: (status) =>
`服务暂时不可用(HTTP ${status}),请稍后重试。`,
outlineLabel: "对话章节",
outlineAttachmentFallback: "图片或文件消息",
},

View File

@ -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,
],

View File

@ -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}`] : [],
);
});
}