feat(chat): enhance error classification and handling for thread busy scenarios, improving user feedback and response management

This commit is contained in:
Anish Sarkar 2026-04-30 14:03:09 +05:30
parent fd4d0817d1
commit 35ea0eae53
6 changed files with 322 additions and 111 deletions

View file

@ -19,6 +19,7 @@ import re
import time import time
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import partial
from typing import Any, Literal from typing import Any, Literal
from uuid import UUID from uuid import UUID
@ -30,6 +31,7 @@ from sqlalchemy.orm import selectinload
from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.checkpointer import get_checkpointer
from app.agents.new_chat.errors import BusyError
from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.new_chat.llm_config import ( from app.agents.new_chat.llm_config import (
AgentConfig, AgentConfig,
@ -315,6 +317,15 @@ def _classify_stream_exception(
flow_label: str, flow_label: str,
) -> tuple[str, str, Literal["info", "warn", "error"], bool, str]: ) -> tuple[str, str, Literal["info", "warn", "error"], bool, str]:
raw = str(exc) raw = str(exc)
if isinstance(exc, BusyError) or "Thread is busy with another request" in raw:
return (
"thread_busy",
"THREAD_BUSY",
"warn",
True,
"Another response is still finishing for this thread. Please try again in a moment.",
)
parsed = _parse_error_payload(raw) parsed = _parse_error_payload(raw)
provider_error_type = "" provider_error_type = ""
if parsed: if parsed:
@ -345,6 +356,37 @@ def _classify_stream_exception(
) )
def _emit_stream_terminal_error(
*,
streaming_service: VercelStreamingService,
flow: str,
request_id: str | None,
thread_id: int,
search_space_id: int,
user_id: str | None,
message: str,
error_kind: str = "server_error",
error_code: str = "SERVER_ERROR",
severity: Literal["info", "warn", "error"] = "error",
is_expected: bool = False,
extra: dict[str, Any] | None = None,
) -> str:
_log_chat_stream_error(
flow=flow,
error_kind=error_kind,
error_code=error_code,
severity=severity,
is_expected=is_expected,
request_id=request_id,
thread_id=thread_id,
search_space_id=search_space_id,
user_id=user_id,
message=message,
extra=extra,
)
return streaming_service.format_error(message, error_code=error_code)
async def _stream_agent_events( async def _stream_agent_events(
agent: Any, agent: Any,
config: dict[str, Any], config: dict[str, Any],
@ -1541,29 +1583,15 @@ async def stream_new_chat(
_premium_reserved = 0 _premium_reserved = 0
_premium_request_id: str | None = None _premium_request_id: str | None = None
def _emit_stream_error( _emit_stream_error = partial(
*, _emit_stream_terminal_error,
message: str, streaming_service=streaming_service,
error_kind: str = "server_error", flow=flow,
error_code: str = "SERVER_ERROR", request_id=request_id,
severity: Literal["info", "warn", "error"] = "error", thread_id=chat_id,
is_expected: bool = False, search_space_id=search_space_id,
extra: dict[str, Any] | None = None, user_id=user_id,
) -> str: )
_log_chat_stream_error(
flow=flow,
error_kind=error_kind,
error_code=error_code,
severity=severity,
is_expected=is_expected,
request_id=request_id,
thread_id=chat_id,
search_space_id=search_space_id,
user_id=user_id,
message=message,
extra=extra,
)
return streaming_service.format_error(message, error_code=error_code)
session = async_session_maker() session = async_session_maker()
try: try:
@ -2380,29 +2408,15 @@ async def stream_resume_chat(
accumulator = start_turn() accumulator = start_turn()
def _emit_stream_error( _emit_stream_error = partial(
*, _emit_stream_terminal_error,
message: str, streaming_service=streaming_service,
error_kind: str = "server_error", flow="resume",
error_code: str = "SERVER_ERROR", request_id=request_id,
severity: Literal["info", "warn", "error"] = "error", thread_id=chat_id,
is_expected: bool = False, search_space_id=search_space_id,
extra: dict[str, Any] | None = None, user_id=user_id,
) -> str: )
_log_chat_stream_error(
flow="resume",
error_kind=error_kind,
error_code=error_code,
severity=severity,
is_expected=is_expected,
request_id=request_id,
thread_id=chat_id,
search_space_id=search_space_id,
user_id=user_id,
message=message,
extra=extra,
)
return streaming_service.format_error(message, error_code=error_code)
session = async_session_maker() session = async_session_maker()
try: try:

View file

@ -1,12 +1,13 @@
import inspect import inspect
import json import json
import logging import logging
from pathlib import Path
import re import re
from pathlib import Path
import pytest import pytest
import app.tasks.chat.stream_new_chat as stream_new_chat_module import app.tasks.chat.stream_new_chat as stream_new_chat_module
from app.agents.new_chat.errors import BusyError
from app.tasks.chat.stream_new_chat import ( from app.tasks.chat.stream_new_chat import (
StreamResult, StreamResult,
_classify_stream_exception, _classify_stream_exception,
@ -130,14 +131,14 @@ def test_stream_error_emission_keeps_machine_error_codes():
format_error_calls = re.findall(r"format_error\(", source) format_error_calls = re.findall(r"format_error\(", source)
emitted_error_codes = set(re.findall(r'error_code="([A-Z_]+)"', source)) emitted_error_codes = set(re.findall(r'error_code="([A-Z_]+)"', source))
# Both new/resume stream paths now route through local emitters that always # All stream paths should route through one shared terminal error emitter.
# pass a machine-readable error_code. assert len(format_error_calls) == 1
assert len(format_error_calls) == 2
assert { assert {
"PREMIUM_QUOTA_EXHAUSTED", "PREMIUM_QUOTA_EXHAUSTED",
"SERVER_ERROR", "SERVER_ERROR",
}.issubset(emitted_error_codes) }.issubset(emitted_error_codes)
assert 'flow: Literal["new", "regenerate"] = "new"' in source assert 'flow: Literal["new", "regenerate"] = "new"' in source
assert "_emit_stream_terminal_error" in source
assert "flow=flow" in source assert "flow=flow" in source
assert 'flow="resume"' in source assert 'flow="resume"' in source
@ -156,6 +157,30 @@ def test_stream_exception_classifies_rate_limited():
assert "temporarily rate-limited" in user_message assert "temporarily rate-limited" in user_message
def test_stream_exception_classifies_thread_busy():
exc = BusyError(request_id="thread-123")
kind, code, severity, is_expected, user_message = _classify_stream_exception(
exc, flow_label="chat"
)
assert kind == "thread_busy"
assert code == "THREAD_BUSY"
assert severity == "warn"
assert is_expected is True
assert "still finishing for this thread" in user_message
def test_stream_exception_classifies_thread_busy_from_message():
exc = Exception("Thread is busy with another request")
kind, code, severity, is_expected, user_message = _classify_stream_exception(
exc, flow_label="chat"
)
assert kind == "thread_busy"
assert code == "THREAD_BUSY"
assert severity == "warn"
assert is_expected is True
assert "still finishing for this thread" in user_message
def test_premium_classification_is_error_code_driven(): def test_premium_classification_is_error_code_driven():
classifier_path = Path(__file__).resolve().parents[3] / "surfsense_web/lib/chat/chat-error-classifier.ts" classifier_path = Path(__file__).resolve().parents[3] / "surfsense_web/lib/chat/chat-error-classifier.ts"
source = classifier_path.read_text(encoding="utf-8") source = classifier_path.read_text(encoding="utf-8")

View file

@ -67,6 +67,7 @@ import {
type ContentPartsState, type ContentPartsState,
FrameBatchedUpdater, FrameBatchedUpdater,
readSSEStream, readSSEStream,
type SSEEvent,
type ThinkingStepData, type ThinkingStepData,
updateThinkingSteps, updateThinkingSteps,
updateToolCall, updateToolCall,
@ -136,6 +137,75 @@ function markInterruptsCompleted(contentParts: Array<{ type: string; result?: un
} }
} }
function toStreamTerminalError(
event: Extract<SSEEvent, { type: "error" }>
): Error & { errorCode?: string } {
return Object.assign(new Error(event.errorText || "Server error"), {
errorCode: event.errorCode,
});
}
async function toHttpResponseError(response: Response): Promise<Error & { errorCode?: string }> {
const statusDefaultCode =
response.status === 409
? "THREAD_BUSY"
: response.status === 429
? "RATE_LIMITED"
: response.status === 401 || response.status === 403
? "AUTH_EXPIRED"
: "SERVER_ERROR";
let rawBody = "";
try {
rawBody = await response.text();
} catch {
// noop
}
let parsedBody: Record<string, unknown> | null = null;
if (rawBody) {
try {
const parsed = JSON.parse(rawBody);
if (typeof parsed === "object" && parsed !== null) {
parsedBody = parsed as Record<string, unknown>;
}
} catch {
// noop
}
}
const detail = parsedBody?.detail;
const detailObject =
typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : null;
const detailMessage = typeof detail === "string" ? detail : undefined;
const topLevelMessage =
typeof parsedBody?.message === "string" ? (parsedBody.message as string) : undefined;
const detailNestedMessage =
typeof detailObject?.message === "string" ? (detailObject.message as string) : undefined;
const topLevelCode =
typeof parsedBody?.errorCode === "string"
? parsedBody.errorCode
: typeof parsedBody?.error_code === "string"
? parsedBody.error_code
: undefined;
const detailCode =
typeof detailObject?.errorCode === "string"
? detailObject.errorCode
: typeof detailObject?.error_code === "string"
? detailObject.error_code
: undefined;
const errorCode = detailCode ?? topLevelCode ?? statusDefaultCode;
const message =
detailNestedMessage ??
detailMessage ??
topLevelMessage ??
`Backend error: ${response.status}`;
return Object.assign(new Error(message), { errorCode });
}
/** /**
* Zod schema for mentioned document info (for type-safe parsing) * Zod schema for mentioned document info (for type-safe parsing)
*/ */
@ -532,6 +602,43 @@ export default function NewChatPage() {
] ]
); );
const handleStreamTerminalError = useCallback(
async ({
error,
flow,
threadId,
assistantMsgId,
accepted,
onAbort,
onAcceptedStreamError,
}: {
error: unknown;
flow: ChatFlow;
threadId: number | null;
assistantMsgId: string;
accepted: boolean;
onAbort?: () => Promise<void>;
onAcceptedStreamError?: () => Promise<void>;
}) => {
if (error instanceof Error && error.name === "AbortError") {
await onAbort?.();
return;
}
if (accepted) {
await onAcceptedStreamError?.();
}
await handleChatFailure({
error,
flow,
threadId,
assistantMsgId: accepted ? assistantMsgId : "no-persist-assistant",
});
},
[handleChatFailure]
);
// Initialize thread and load messages // Initialize thread and load messages
// For new chats (no urlChatId), we use lazy creation - thread is created on first message // For new chats (no urlChatId), we use lazy creation - thread is created on first message
const initializeThread = useCallback(async () => { const initializeThread = useCallback(async () => {
@ -880,6 +987,7 @@ export default function NewChatPage() {
const { contentParts, toolCallIndices } = contentPartsState; const { contentParts, toolCallIndices } = contentPartsState;
let wasInterrupted = false; let wasInterrupted = false;
let tokenUsageData: Record<string, unknown> | null = null; let tokenUsageData: Record<string, unknown> | null = null;
let newAccepted = false;
// Add placeholder assistant message // Add placeholder assistant message
setMessages((prev) => [ setMessages((prev) => [
@ -951,8 +1059,9 @@ export default function NewChatPage() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Backend error: ${response.status}`); throw await toHttpResponseError(response);
} }
newAccepted = true;
const flushMessages = () => { const flushMessages = () => {
setMessages((prev) => setMessages((prev) =>
@ -1106,9 +1215,7 @@ export default function NewChatPage() {
break; break;
case "error": case "error":
throw Object.assign(new Error(parsed.errorText || "Server error"), { throw toStreamTerminalError(parsed);
errorCode: parsed.errorCode,
});
} }
} }
@ -1137,29 +1244,29 @@ export default function NewChatPage() {
} }
} catch (error) { } catch (error) {
batcher.dispose(); batcher.dispose();
if (error instanceof Error && error.name === "AbortError") { await handleStreamTerminalError({
// Request was cancelled by user - persist partial response if any content was received
const hasContent = contentParts.some(
(part) =>
(part.type === "text" && part.text.length > 0) ||
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
await persistAssistantTurn({
threadId: currentThreadId,
assistantMsgId,
content: partialContent,
logContext: "partial new chat",
});
}
return;
}
await handleChatFailure({
error, error,
flow: "new", flow: "new",
threadId: currentThreadId, threadId: currentThreadId,
assistantMsgId, assistantMsgId,
accepted: newAccepted,
onAbort: async () => {
// Request was cancelled by user - persist partial response if any content was received
const hasContent = contentParts.some(
(part) =>
(part.type === "text" && part.text.length > 0) ||
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
await persistAssistantTurn({
threadId: currentThreadId,
assistantMsgId,
content: partialContent,
logContext: "partial new chat",
});
}
},
}); });
} finally { } finally {
setIsRunning(false); setIsRunning(false);
@ -1183,7 +1290,7 @@ export default function NewChatPage() {
pendingUserImageUrls, pendingUserImageUrls,
setPendingUserImageUrls, setPendingUserImageUrls,
toolsWithUI, toolsWithUI,
handleChatFailure, handleStreamTerminalError,
persistAssistantTurn, persistAssistantTurn,
] ]
); );
@ -1221,6 +1328,7 @@ export default function NewChatPage() {
}; };
const { contentParts, toolCallIndices } = contentPartsState; const { contentParts, toolCallIndices } = contentPartsState;
let tokenUsageData: Record<string, unknown> | null = null; let tokenUsageData: Record<string, unknown> | null = null;
let resumeAccepted = false;
const existingMsg = messages.find((m) => m.id === assistantMsgId); const existingMsg = messages.find((m) => m.id === assistantMsgId);
if (existingMsg && Array.isArray(existingMsg.content)) { if (existingMsg && Array.isArray(existingMsg.content)) {
@ -1302,8 +1410,9 @@ export default function NewChatPage() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Backend error: ${response.status}`); throw await toHttpResponseError(response);
} }
resumeAccepted = true;
const flushMessages = () => { const flushMessages = () => {
setMessages((prev) => setMessages((prev) =>
@ -1415,9 +1524,7 @@ export default function NewChatPage() {
break; break;
case "error": case "error":
throw Object.assign(new Error(parsed.errorText || "Server error"), { throw toStreamTerminalError(parsed);
errorCode: parsed.errorCode,
});
} }
} }
@ -1435,14 +1542,12 @@ export default function NewChatPage() {
} }
} catch (error) { } catch (error) {
batcher.dispose(); batcher.dispose();
if (error instanceof Error && error.name === "AbortError") { await handleStreamTerminalError({
return;
}
await handleChatFailure({
error, error,
flow: "resume", flow: "resume",
threadId: resumeThreadId, threadId: resumeThreadId,
assistantMsgId, assistantMsgId,
accepted: resumeAccepted,
}); });
} finally { } finally {
setIsRunning(false); setIsRunning(false);
@ -1455,7 +1560,7 @@ export default function NewChatPage() {
searchSpaceId, searchSpaceId,
tokenUsageStore, tokenUsageStore,
toolsWithUI, toolsWithUI,
handleChatFailure, handleStreamTerminalError,
persistAssistantTurn, persistAssistantTurn,
] ]
); );
@ -1644,7 +1749,7 @@ export default function NewChatPage() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Backend error: ${response.status}`); throw await toHttpResponseError(response);
} }
regenerateAccepted = true; regenerateAccepted = true;
@ -1741,9 +1846,7 @@ export default function NewChatPage() {
break; break;
case "error": case "error":
throw Object.assign(new Error(parsed.errorText || "Server error"), { throw toStreamTerminalError(parsed);
errorCode: parsed.errorCode,
});
} }
} }
@ -1772,25 +1875,25 @@ export default function NewChatPage() {
trackChatResponseReceived(searchSpaceId, threadId); trackChatResponseReceived(searchSpaceId, threadId);
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
batcher.dispose(); batcher.dispose();
if (regenerateAccepted && !userPersisted) { await handleStreamTerminalError({
const persistedUserMsgId = await persistUserTurn({
threadId,
userMsgId,
content: userContentToPersist,
mentionedDocs: sourceMentionedDocs,
logContext: "regenerated (stream error)",
});
userPersisted = Boolean(persistedUserMsgId);
}
await handleChatFailure({
error, error,
flow: "regenerate", flow: "regenerate",
threadId, threadId,
assistantMsgId: regenerateAccepted ? assistantMsgId : "no-persist-assistant", assistantMsgId,
accepted: regenerateAccepted,
onAcceptedStreamError: async () => {
if (!userPersisted) {
const persistedUserMsgId = await persistUserTurn({
threadId,
userMsgId,
content: userContentToPersist,
mentionedDocs: sourceMentionedDocs,
logContext: "regenerated (stream error)",
});
userPersisted = Boolean(persistedUserMsgId);
}
},
}); });
} finally { } finally {
setIsRunning(false); setIsRunning(false);
@ -1806,7 +1909,7 @@ export default function NewChatPage() {
setMessageDocumentsMap, setMessageDocumentsMap,
tokenUsageStore, tokenUsageStore,
toolsWithUI, toolsWithUI,
handleChatFailure, handleStreamTerminalError,
persistAssistantTurn, persistAssistantTurn,
persistUserTurn, persistUserTurn,
] ]

View file

@ -104,7 +104,13 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
setMessages((prev) => prev.filter((m) => m.id !== assistantId)); setMessages((prev) => prev.filter((m) => m.id !== assistantId));
return; return;
} }
throw new Error(`Stream error: ${response.status}`); const body = await response.text().catch(() => "");
const errorCode = response.status === 409 ? "THREAD_BUSY" : "SERVER_ERROR";
const message =
errorCode === "THREAD_BUSY"
? "A previous response is still stopping. Please try again in a moment."
: `Stream error: ${response.status}`;
throw Object.assign(new Error(body || message), { errorCode });
} }
for await (const event of readSSEStream(response)) { for await (const event of readSSEStream(response)) {
@ -115,10 +121,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + event.delta } : m)) prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + event.delta } : m))
); );
} else if (event.type === "error") { } else if (event.type === "error") {
const message =
event.errorCode === "THREAD_BUSY"
? "A previous response is still stopping. Please try again in a moment."
: event.errorText;
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) => (m.id === assistantId ? { ...m, content: m.content || message } : m))
m.id === assistantId ? { ...m, content: m.content || event.errorText } : m
)
); );
} else if ("type" in event && event.type === "data-token-usage") { } else if ("type" in event && event.type === "data-token-usage") {
// After streaming completes, refresh quota // After streaming completes, refresh quota

View file

@ -48,6 +48,48 @@ function parseCaptchaError(status: number, body: string): string | null {
return null; return null;
} }
function normalizeFreeChatErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "An unexpected error occurred";
const code = (error as Error & { errorCode?: string }).errorCode;
if (code === "THREAD_BUSY") {
return "A previous response is still stopping. Please try again in a moment.";
}
return error.message || "An unexpected error occurred";
}
function toFreeChatHttpError(status: number, body: string): Error & { errorCode?: string } {
let errorCode: string | undefined;
let message = body || `Server error: ${status}`;
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
const detail =
typeof parsed.detail === "object" && parsed.detail !== null
? (parsed.detail as Record<string, unknown>)
: null;
errorCode =
(typeof detail?.error_code === "string" ? detail.error_code : undefined) ??
(typeof detail?.errorCode === "string" ? detail.errorCode : undefined) ??
(typeof parsed.error_code === "string" ? parsed.error_code : undefined) ??
(typeof parsed.errorCode === "string" ? parsed.errorCode : undefined);
message =
(typeof detail?.message === "string" ? detail.message : undefined) ??
(typeof parsed.message === "string" ? parsed.message : undefined) ??
(typeof parsed.detail === "string" ? parsed.detail : undefined) ??
message;
} catch {
// non-json response
}
if (!errorCode) {
if (status === 409) errorCode = "THREAD_BUSY";
else if (status === 429) errorCode = "RATE_LIMITED";
else if (status === 401 || status === 403) errorCode = "AUTH_EXPIRED";
else errorCode = "SERVER_ERROR";
}
return Object.assign(new Error(message), { errorCode });
}
export function FreeChatPage() { export function FreeChatPage() {
const anonMode = useAnonymousMode(); const anonMode = useAnonymousMode();
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : ""; const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
@ -117,7 +159,7 @@ export function FreeChatPage() {
const body = await response.text().catch(() => ""); const body = await response.text().catch(() => "");
const captchaCode = parseCaptchaError(response.status, body); const captchaCode = parseCaptchaError(response.status, body);
if (captchaCode) return "captcha"; if (captchaCode) return "captcha";
throw new Error(body || `Server error: ${response.status}`); throw toFreeChatHttpError(response.status, body);
} }
const currentThinkingSteps = new Map<string, ThinkingStepData>(); const currentThinkingSteps = new Map<string, ThinkingStepData>();
@ -187,7 +229,9 @@ export function FreeChatPage() {
break; break;
case "error": case "error":
throw new Error(parsed.errorText || "Server error"); throw Object.assign(new Error(parsed.errorText || "Server error"), {
errorCode: parsed.errorCode,
});
} }
} }
batcher.flush(); batcher.flush();
@ -277,7 +321,7 @@ export function FreeChatPage() {
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") return; if (error instanceof Error && error.name === "AbortError") return;
console.error("[FreeChatPage] Chat error:", error); console.error("[FreeChatPage] Chat error:", error);
const errorText = error instanceof Error ? error.message : "An unexpected error occurred"; const errorText = normalizeFreeChatErrorMessage(error);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
@ -336,7 +380,7 @@ export function FreeChatPage() {
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") return; if (error instanceof Error && error.name === "AbortError") return;
console.error("[FreeChatPage] Retry error:", error); console.error("[FreeChatPage] Retry error:", error);
const errorText = error instanceof Error ? error.message : "An unexpected error occurred"; const errorText = normalizeFreeChatErrorMessage(error);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId

View file

@ -2,6 +2,7 @@ export type ChatFlow = "new" | "resume" | "regenerate";
export type ChatErrorKind = export type ChatErrorKind =
| "premium_quota_exhausted" | "premium_quota_exhausted"
| "thread_busy"
| "auth_expired" | "auth_expired"
| "rate_limited" | "rate_limited"
| "network_offline" | "network_offline"
@ -144,6 +145,22 @@ export function classifyChatError(input: RawChatErrorInput): NormalizedChatError
}; };
} }
if (
errorCode === "THREAD_BUSY"
) {
return {
kind: "thread_busy",
channel: "toast",
severity: "warn",
telemetryEvent: "chat_blocked",
isExpected: true,
userMessage: "A previous response is still stopping. Please try again in a moment.",
rawMessage,
errorCode: errorCode ?? "THREAD_BUSY",
details: { flow: input.flow },
};
}
if ( if (
errorCode === "AUTH_EXPIRED" || errorCode === "AUTH_EXPIRED" ||
errorCode === "UNAUTHORIZED" errorCode === "UNAUTHORIZED"