feat(chat): unify error handling and logging for chat operations, enhancing clarity and consistency in error reporting

This commit is contained in:
Anish Sarkar 2026-04-30 11:56:41 +05:30
parent 222b27183f
commit d64543686f
6 changed files with 831 additions and 171 deletions

View file

@ -49,6 +49,10 @@ import { useMessagesSync } from "@/hooks/use-messages-sync";
import { getAgentFilesystemSelection } from "@/lib/agent-filesystem";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import {
classifyChatError,
type ChatFlow,
} from "@/lib/chat/chat-error-classifier";
import { convertToThreadMessage } from "@/lib/chat/message-utils";
import {
isPodcastGenerating,
@ -84,7 +88,8 @@ import {
import { NotFoundError } from "@/lib/error";
import {
trackChatCreated,
trackChatError,
trackChatBlocked,
trackChatErrorDetailed,
trackChatMessageSent,
trackChatResponseReceived,
} from "@/lib/posthog/events";
@ -201,26 +206,6 @@ const BASE_TOOLS_WITH_UI = new Set([
// "write_todos", // Disabled for now
]);
const PREMIUM_QUOTA_ASSISTANT_MESSAGE =
"I cant continue with the current premium model because your premium tokens are exhausted. Switch to a free model or buy more tokens to continue.";
function getPinnedPremiumQuotaErrorMessage(error: unknown): string | null {
if (!(error instanceof Error)) return null;
const withCode = error as Error & { errorCode?: string };
if (withCode.errorCode === "PREMIUM_QUOTA_EXHAUSTED") {
return error.message;
}
const normalized = error.message.toLowerCase();
if (
!normalized.includes("premium tokens exhausted")
&& !normalized.includes("premium token quota exceeded")
&& !normalized.includes("buy more tokens")
) {
return null;
}
return error.message;
}
export default function NewChatPage() {
const params = useParams();
const queryClient = useQueryClient();
@ -378,6 +363,81 @@ export default function NewChatPage() {
return Number.isNaN(parsed) ? 0 : parsed;
}, [params.chat_id]);
const handleChatFailure = useCallback(
async ({
error,
flow,
threadId,
assistantMsgId,
}: {
error: unknown;
flow: ChatFlow;
threadId: number | null;
assistantMsgId: string;
}) => {
const normalized = classifyChatError({
error,
flow,
context: {
searchSpaceId,
threadId,
},
});
const logger =
normalized.severity === "error"
? console.error
: normalized.severity === "warn"
? console.warn
: console.info;
logger(`[NewChatPage] ${flow} ${normalized.kind}:`, error);
const telemetryPayload = {
flow,
kind: normalized.kind,
error_code: normalized.errorCode,
severity: normalized.severity,
is_expected: normalized.isExpected,
message: normalized.userMessage,
};
if (normalized.telemetryEvent === "chat_blocked") {
trackChatBlocked(searchSpaceId, threadId, telemetryPayload);
} else {
trackChatErrorDetailed(searchSpaceId, threadId, telemetryPayload);
}
if (normalized.channel === "silent") {
return;
}
if (normalized.channel === "pinned_inline") {
if (threadId) {
setPremiumAlertForThread({
threadId,
message: normalized.userMessage,
userId: currentUser?.id ?? null,
});
}
if (normalized.assistantMessage) {
await persistAssistantErrorMessage({
threadId,
assistantMsgId,
text: normalized.assistantMessage,
});
}
return;
}
toast.error(normalized.userMessage);
},
[
currentUser?.id,
persistAssistantErrorMessage,
searchSpaceId,
setPremiumAlertForThread,
]
);
// Initialize thread and load messages
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
const initializeThread = useCallback(async () => {
@ -1018,36 +1078,11 @@ export default function NewChatPage() {
}
return;
}
const premiumQuotaAlertMessage = getPinnedPremiumQuotaErrorMessage(error);
if (premiumQuotaAlertMessage) {
console.info("[NewChatPage] Premium quota exhausted:", error);
} else {
console.error("[NewChatPage] Chat error:", error);
}
// Track chat error
trackChatError(
searchSpaceId,
currentThreadId,
error instanceof Error ? error.message : "Unknown error"
);
if (premiumQuotaAlertMessage) {
setPremiumAlertForThread({
threadId: currentThreadId,
message: premiumQuotaAlertMessage,
userId: currentUser?.id ?? null,
});
} else {
toast.error("Failed to get response. Please try again.");
}
await persistAssistantErrorMessage({
await handleChatFailure({
error,
flow: "new",
threadId: currentThreadId,
assistantMsgId,
text:
(premiumQuotaAlertMessage
? PREMIUM_QUOTA_ASSISTANT_MESSAGE
: undefined) ?? "Sorry, there was an error. Please try again.",
});
} finally {
setIsRunning(false);
@ -1071,8 +1106,7 @@ export default function NewChatPage() {
pendingUserImageUrls,
setPendingUserImageUrls,
toolsWithUI,
setPremiumAlertForThread,
persistAssistantErrorMessage,
handleChatFailure,
]
);
@ -1333,28 +1367,11 @@ export default function NewChatPage() {
if (error instanceof Error && error.name === "AbortError") {
return;
}
const premiumQuotaAlertMessage = getPinnedPremiumQuotaErrorMessage(error);
if (premiumQuotaAlertMessage) {
console.info("[NewChatPage] Premium quota exhausted during resume:", error);
} else {
console.error("[NewChatPage] Resume error:", error);
}
if (premiumQuotaAlertMessage) {
setPremiumAlertForThread({
threadId: resumeThreadId,
message: premiumQuotaAlertMessage,
userId: currentUser?.id ?? null,
});
} else {
toast.error("Failed to resume. Please try again.");
}
await persistAssistantErrorMessage({
await handleChatFailure({
error,
flow: "resume",
threadId: resumeThreadId,
assistantMsgId,
text:
(premiumQuotaAlertMessage
? PREMIUM_QUOTA_ASSISTANT_MESSAGE
: undefined) ?? "Sorry, there was an error. Please try again.",
});
} finally {
setIsRunning(false);
@ -1365,11 +1382,9 @@ export default function NewChatPage() {
pendingInterrupt,
messages,
searchSpaceId,
currentUser?.id,
tokenUsageStore,
toolsWithUI,
setPremiumAlertForThread,
persistAssistantErrorMessage,
handleChatFailure,
]
);
@ -1491,15 +1506,6 @@ export default function NewChatPage() {
userQueryToDisplay = newUserQuery;
}
// Remove the last two messages (user + assistant) from the UI immediately
// The backend will also delete them from the database
setMessages((prev) => {
if (prev.length >= 2) {
return prev.slice(0, -2);
}
return prev;
});
// Start streaming
setIsRunning(true);
const controller = new AbortController();
@ -1530,19 +1536,9 @@ export default function NewChatPage() {
createdAt: new Date(),
metadata: isEdit ? undefined : originalUserMessageMetadata,
};
setMessages((prev) => [...prev, userMessage]);
// Add placeholder assistant message
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
role: "assistant",
content: [{ type: "text", text: "" }],
createdAt: new Date(),
},
]);
const userContentToPersist = isEdit
? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }];
try {
const selection = await getAgentFilesystemSelection(searchSpaceId);
const requestBody: Record<string, unknown> = {
@ -1570,6 +1566,22 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`);
}
// Only switch UI to regenerated placeholder messages after the backend accepts
// regenerate. This avoids local message loss when regenerate fails early (e.g. 400).
setMessages((prev) => {
const base = prev.length >= 2 ? prev.slice(0, -2) : prev;
return [
...base,
userMessage,
{
id: assistantMsgId,
role: "assistant",
content: [{ type: "text", text: "" }],
createdAt: new Date(),
},
];
});
const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
@ -1654,10 +1666,6 @@ export default function NewChatPage() {
if (contentParts.length > 0) {
try {
// Persist user message (for both edit and reload modes, since backend deleted it)
const userContentToPersist = isEdit
? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }];
const savedUserMessage = await appendMessage(threadId, {
role: "user",
content: userContentToPersist,
@ -1692,33 +1700,11 @@ export default function NewChatPage() {
return;
}
batcher.dispose();
const premiumQuotaAlertMessage = getPinnedPremiumQuotaErrorMessage(error);
if (premiumQuotaAlertMessage) {
console.info("[NewChatPage] Premium quota exhausted during regeneration:", error);
} else {
console.error("[NewChatPage] Regeneration error:", error);
}
trackChatError(
searchSpaceId,
threadId,
error instanceof Error ? error.message : "Unknown error"
);
if (premiumQuotaAlertMessage) {
setPremiumAlertForThread({
threadId,
message: premiumQuotaAlertMessage,
userId: currentUser?.id ?? null,
});
} else {
toast.error("Failed to regenerate response. Please try again.");
}
await persistAssistantErrorMessage({
await handleChatFailure({
error,
flow: "regenerate",
threadId,
assistantMsgId,
text:
(premiumQuotaAlertMessage
? PREMIUM_QUOTA_ASSISTANT_MESSAGE
: undefined) ?? "Sorry, there was an error. Please try again.",
});
} finally {
setIsRunning(false);
@ -1730,11 +1716,9 @@ export default function NewChatPage() {
searchSpaceId,
messages,
disabledTools,
currentUser?.id,
tokenUsageStore,
toolsWithUI,
setPremiumAlertForThread,
persistAssistantErrorMessage,
handleChatFailure,
]
);

View file

@ -0,0 +1,273 @@
export type ChatFlow = "new" | "resume" | "regenerate";
export type ChatErrorKind =
| "premium_quota_exhausted"
| "auth_expired"
| "rate_limited"
| "network_offline"
| "stream_interrupted"
| "stream_parse_error"
| "tool_execution_error"
| "persist_message_failed"
| "server_error"
| "unknown";
export type ChatErrorChannel = "pinned_inline" | "toast" | "silent";
export type ChatTelemetryEvent = "chat_blocked" | "chat_error";
export type ChatErrorSeverity = "info" | "warn" | "error";
export interface NormalizedChatError {
kind: ChatErrorKind;
channel: ChatErrorChannel;
severity: ChatErrorSeverity;
telemetryEvent: ChatTelemetryEvent;
isExpected: boolean;
userMessage: string;
assistantMessage?: string;
rawMessage?: string;
errorCode?: string;
details?: Record<string, unknown>;
}
export interface RawChatErrorInput {
error: unknown;
flow: ChatFlow;
context?: {
searchSpaceId?: number;
threadId?: number | null;
};
}
export const PREMIUM_QUOTA_ASSISTANT_MESSAGE =
"I cant continue with the current premium model because your premium tokens are exhausted. Switch to a free model or buy more tokens to continue.";
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error);
} catch {
return "Unknown error";
}
}
function getErrorCode(error: unknown, parsedJson: Record<string, unknown> | null): string | undefined {
if (error instanceof Error) {
const withCode = error as Error & { errorCode?: string };
if (withCode.errorCode) return withCode.errorCode;
}
if (typeof error === "object" && error !== null) {
const withCode = error as { errorCode?: unknown };
if (typeof withCode.errorCode === "string" && withCode.errorCode) {
return withCode.errorCode;
}
}
if (parsedJson) {
const topLevelCode = parsedJson.errorCode;
if (typeof topLevelCode === "string" && topLevelCode) {
return topLevelCode;
}
}
return undefined;
}
function parseEmbeddedJson(text: string): Record<string, unknown> | null {
const candidates = [text];
const firstBraceIdx = text.indexOf("{");
if (firstBraceIdx >= 0) {
candidates.push(text.slice(firstBraceIdx));
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (typeof parsed === "object" && parsed !== null) {
return parsed as Record<string, unknown>;
}
} catch {
// noop
}
}
return null;
}
function inferProviderErrorType(parsedJson: Record<string, unknown> | null): string | undefined {
if (!parsedJson) return undefined;
const topLevelType = parsedJson.type;
if (typeof topLevelType === "string" && topLevelType) return topLevelType;
const nestedError = parsedJson.error;
if (typeof nestedError === "object" && nestedError !== null) {
const nestedType = (nestedError as Record<string, unknown>).type;
if (typeof nestedType === "string" && nestedType) return nestedType;
}
return undefined;
}
export function classifyChatError(input: RawChatErrorInput): NormalizedChatError {
const { error } = input;
const rawMessage = getErrorMessage(error);
const parsedJson = parseEmbeddedJson(rawMessage);
const errorCode = getErrorCode(error, parsedJson);
const providerErrorType = inferProviderErrorType(parsedJson);
const providerTypeNormalized = providerErrorType?.toLowerCase() ?? "";
const errorName = error instanceof Error ? error.name : undefined;
if (errorName === "AbortError") {
return {
kind: "stream_interrupted",
channel: "silent",
severity: "info",
telemetryEvent: "chat_error",
isExpected: true,
userMessage: "Request canceled.",
rawMessage,
errorCode,
details: { flow: input.flow },
};
}
if (errorCode === "PREMIUM_QUOTA_EXHAUSTED") {
return {
kind: "premium_quota_exhausted",
channel: "pinned_inline",
severity: "info",
telemetryEvent: "chat_blocked",
isExpected: true,
userMessage:
"Buy more tokens to continue with this model, or switch to a free model.",
assistantMessage: PREMIUM_QUOTA_ASSISTANT_MESSAGE,
rawMessage,
errorCode: errorCode ?? "PREMIUM_QUOTA_EXHAUSTED",
details: { flow: input.flow },
};
}
if (
errorCode === "AUTH_EXPIRED" ||
errorCode === "UNAUTHORIZED"
) {
return {
kind: "auth_expired",
channel: "toast",
severity: "warn",
telemetryEvent: "chat_error",
isExpected: true,
userMessage: "Your session expired. Please sign in again.",
rawMessage,
errorCode: errorCode ?? "AUTH_EXPIRED",
details: { flow: input.flow },
};
}
if (
errorCode === "RATE_LIMITED" ||
providerTypeNormalized === "rate_limit_error"
) {
return {
kind: "rate_limited",
channel: "toast",
severity: "warn",
telemetryEvent: "chat_blocked",
isExpected: true,
userMessage:
"This model is temporarily rate-limited. Please try again in a few seconds or switch models.",
rawMessage,
errorCode: errorCode ?? "RATE_LIMITED",
details: { flow: input.flow, providerErrorType },
};
}
if (
errorCode === "NETWORK_ERROR"
) {
return {
kind: "network_offline",
channel: "toast",
severity: "warn",
telemetryEvent: "chat_error",
isExpected: true,
userMessage: "Connection issue detected. Check your internet and try again.",
rawMessage,
errorCode: errorCode ?? "NETWORK_ERROR",
details: { flow: input.flow },
};
}
if (
errorCode === "STREAM_PARSE_ERROR"
) {
return {
kind: "stream_parse_error",
channel: "toast",
severity: "error",
telemetryEvent: "chat_error",
isExpected: false,
userMessage: "We hit a response formatting issue. Please try again.",
rawMessage,
errorCode: errorCode ?? "STREAM_PARSE_ERROR",
details: { flow: input.flow },
};
}
if (
errorCode === "TOOL_EXECUTION_ERROR"
) {
return {
kind: "tool_execution_error",
channel: "toast",
severity: "error",
telemetryEvent: "chat_error",
isExpected: false,
userMessage: "A tool failed while processing your request. Please try again.",
rawMessage,
errorCode: errorCode ?? "TOOL_EXECUTION_ERROR",
details: { flow: input.flow },
};
}
if (
errorCode === "PERSIST_MESSAGE_FAILED"
) {
return {
kind: "persist_message_failed",
channel: "toast",
severity: "error",
telemetryEvent: "chat_error",
isExpected: false,
userMessage: "Response generated, but saving failed. Please retry once.",
rawMessage,
errorCode: errorCode ?? "PERSIST_MESSAGE_FAILED",
details: { flow: input.flow },
};
}
if (
errorCode === "SERVER_ERROR"
) {
return {
kind: "server_error",
channel: "toast",
severity: "error",
telemetryEvent: "chat_error",
isExpected: false,
userMessage: "We couldnt complete this response right now. Please try again.",
rawMessage,
errorCode: errorCode ?? "SERVER_ERROR",
details: { flow: input.flow, providerErrorType },
};
}
return {
kind: "unknown",
channel: "toast",
severity: "error",
telemetryEvent: "chat_error",
isExpected: false,
userMessage: "We couldnt complete this response right now. Please try again.",
rawMessage,
errorCode,
details: { flow: input.flow, providerErrorType },
};
}

View file

@ -1,5 +1,6 @@
import posthog from "posthog-js";
import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import type { ChatErrorKind, ChatFlow, ChatErrorSeverity } from "@/lib/chat/chat-error-classifier";
/**
* PostHog Analytics Event Definitions
@ -139,6 +140,55 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st
});
}
export interface ChatFailureTelemetry {
flow: ChatFlow;
kind: ChatErrorKind;
error_code?: string;
severity: ChatErrorSeverity;
is_expected: boolean;
message?: string;
}
export function trackChatBlocked(
searchSpaceId: number,
chatId: number | null,
payload: ChatFailureTelemetry
) {
safeCapture(
"chat_blocked",
compact({
search_space_id: searchSpaceId,
chat_id: chatId ?? undefined,
flow: payload.flow,
kind: payload.kind,
error_code: payload.error_code,
severity: payload.severity,
is_expected: payload.is_expected,
message: payload.message,
})
);
}
export function trackChatErrorDetailed(
searchSpaceId: number,
chatId: number | null,
payload: ChatFailureTelemetry
) {
safeCapture(
"chat_error",
compact({
search_space_id: searchSpaceId,
chat_id: chatId ?? undefined,
flow: payload.flow,
kind: payload.kind,
error_code: payload.error_code,
severity: payload.severity,
is_expected: payload.is_expected,
message: payload.message,
})
);
}
/**
* Track a message sent from the unauthenticated "free" / anonymous chat
* flow. This is intentionally a separate event from `chat_message_sent`