From 33bfcadf97aae603dd997809eb88316e81c7f19f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 26 May 2026 22:15:49 +0800 Subject: [PATCH] refactor: extract chat route error helpers --- app/(chat)/api/chat/_lib/errors.ts | 188 +++++++++++++++++++++ app/(chat)/api/chat/_lib/persistence.ts | 24 +++ app/(chat)/api/chat/route.ts | 209 ++---------------------- 3 files changed, 223 insertions(+), 198 deletions(-) create mode 100644 app/(chat)/api/chat/_lib/errors.ts create mode 100644 app/(chat)/api/chat/_lib/persistence.ts diff --git a/app/(chat)/api/chat/_lib/errors.ts b/app/(chat)/api/chat/_lib/errors.ts new file mode 100644 index 000000000..02d935472 --- /dev/null +++ b/app/(chat)/api/chat/_lib/errors.ts @@ -0,0 +1,188 @@ +import { NextResponse } from "next/server"; +import { getRequestErrorMessage } from "@/lib/request-error"; +import { generateUUID } from "@/lib/utils"; + +const DEFAULT_CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS = 55_000; + +export class OperationTimeoutError extends Error { + constructor(operation: string, timeoutMs: number) { + super(`${operation} timed out after ${timeoutMs}ms`); + this.name = "OperationTimeoutError"; + } +} + +export function getChatStreamErrorMessage(error: unknown): string { + const rawMessage = + error instanceof Error ? String(error.message || "").trim() : String(error || "").trim(); + const normalized = rawMessage.toLowerCase(); + + if ( + normalized.includes("token quota is not enough") || + normalized.includes("pre_consume_token_quota_failed") || + normalized.includes("quota is not enough") + ) { + return "当前对话模型额度不足,请切换其他模型,或联系管理员补充该模型额度。"; + } + + if ( + normalized.includes("timeout") || + normalized.includes("timed out") || + normalized.includes("body timeout") + ) { + return "当前对话模型响应超时,请重试,或切换到其他可用模型。"; + } + + if (rawMessage) { + return `当前对话暂时失败:${rawMessage}`; + } + + return "当前对话暂时失败,请重试或切换其他模型。"; +} + +function resolveChatApiFailure(error: unknown) { + const detailMessage = getRequestErrorMessage(error); + const normalized = String(detailMessage || "").toLowerCase(); + + if ( + normalized.includes("payload too large") || + normalized.includes("request entity too large") || + normalized.includes("body exceeded") || + normalized.includes("content length") || + normalized.includes("413") + ) { + return { + code: "payload_too_large:chat", + message: "本次请求内容过大,请减少图片或文字内容后重试。", + status: 413, + category: "payload_too_large", + detailMessage, + }; + } + + if ( + error instanceof OperationTimeoutError || + normalized.includes("timeout") || + normalized.includes("timed out") || + normalized.includes("body timeout") || + normalized.includes("aborterror") || + normalized.includes("aborted") + ) { + return { + code: "timeout:chat", + message: "当前模型响应超时,请重试,或切换到其他可用模型。", + status: 504, + category: "timeout", + detailMessage, + }; + } + + if ( + normalized.includes("fetch failed") || + normalized.includes("econnreset") || + normalized.includes("econnrefused") || + normalized.includes("enotfound") || + normalized.includes("etimedout") || + normalized.includes("und_err") || + normalized.includes("socket hang up") + ) { + return { + code: "server_error:chat", + message: "模型服务连接失败,请稍后重试,或切换其他可用模型。", + status: 502, + category: "upstream_connection", + detailMessage, + }; + } + + if ( + normalized.includes("database") || + normalized.includes("postgres") || + normalized.includes("drizzle") + ) { + return { + code: "server_error:chat", + message: "服务数据库暂时繁忙,请稍后重试。", + status: 503, + category: "database", + detailMessage, + }; + } + + return { + code: "server_error:chat", + message: "服务临时繁忙,请稍后重试;如果连续出现,请联系管理员。", + status: 500, + category: "unknown", + detailMessage, + }; +} + +export function createChatApiFailureResponse( + error: unknown, + vercelId: string | null +) { + const failure = resolveChatApiFailure(error); + const traceId = generateUUID(); + + console.error("[Chat API] Unhandled request failure:", { + traceId, + vercelId, + category: failure.category, + status: failure.status, + detailMessage: failure.detailMessage, + error, + }); + + return NextResponse.json( + { + code: failure.code, + message: failure.message, + cause: `trace:${traceId}`, + }, + { status: failure.status } + ); +} + +export async function withOperationTimeout( + operation: Promise, + operationName: string, + timeoutMs: number +): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new OperationTimeoutError(operationName, timeoutMs)), + timeoutMs + ); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +export function getRemainingBudgetMs(startedAt: number, budgetMs: number) { + return Math.max(0, budgetMs - (Date.now() - startedAt)); +} + +export function getBudgetedTimeout(params: { + startedAt: number; + preferredMs: number; + minimumMs?: number; + budgetMs?: number; +}) { + const minimumMs = params.minimumMs ?? 6_000; + const remainingMs = getRemainingBudgetMs( + params.startedAt, + params.budgetMs ?? DEFAULT_CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS + ); + if (remainingMs < minimumMs) { + return null; + } + return Math.min(params.preferredMs, remainingMs); +} diff --git a/app/(chat)/api/chat/_lib/persistence.ts b/app/(chat)/api/chat/_lib/persistence.ts new file mode 100644 index 000000000..b608efe26 --- /dev/null +++ b/app/(chat)/api/chat/_lib/persistence.ts @@ -0,0 +1,24 @@ +export function normalizeAssistantPartsForPersistence( + parts: Array> | undefined +) { + const safeParts = Array.isArray(parts) ? parts.filter(Boolean) : []; + return safeParts.filter((part) => { + const partType = String(part?.type || "").trim(); + if (!partType) return false; + if ( + partType === "data-keepalive" || + partType === "step-start" || + partType === "step-finish" + ) { + return false; + } + return true; + }); +} + +export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 69e2397e9..29573cb86 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -247,6 +247,17 @@ import type { import { generateTitleFromUserMessage } from "../../actions"; import { type PostRequestBody, postRequestBodySchema } from "./schema"; import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; +import { + createChatApiFailureResponse, + getBudgetedTimeout, + getChatStreamErrorMessage, + OperationTimeoutError, + withOperationTimeout, +} from "./_lib/errors"; +import { + bufferToArrayBuffer, + normalizeAssistantPartsForPersistence, +} from "./_lib/persistence"; import { buildAgentTargetLockPayload, createSseResponse, @@ -788,154 +799,6 @@ type AgentVideoPlan = { recommendedVideoParams?: AgentRecommendedVideoParams; }; -function getChatStreamErrorMessage(error: unknown): string { - const rawMessage = - error instanceof Error ? String(error.message || "").trim() : String(error || "").trim(); - const normalized = rawMessage.toLowerCase(); - - if ( - normalized.includes("token quota is not enough") || - normalized.includes("pre_consume_token_quota_failed") || - normalized.includes("quota is not enough") - ) { - return "当前对话模型额度不足,请切换其他模型,或联系管理员补充该模型额度。"; - } - - if ( - normalized.includes("timeout") || - normalized.includes("timed out") || - normalized.includes("body timeout") - ) { - return "当前对话模型响应超时,请重试,或切换到其他可用模型。"; - } - - if (rawMessage) { - return `当前对话暂时失败:${rawMessage}`; - } - - return "当前对话暂时失败,请重试或切换其他模型。"; -} - -function resolveChatApiFailure(error: unknown) { - const detailMessage = getRequestErrorMessage(error); - const normalized = String(detailMessage || "").toLowerCase(); - - if ( - normalized.includes("payload too large") || - normalized.includes("request entity too large") || - normalized.includes("body exceeded") || - normalized.includes("content length") || - normalized.includes("413") - ) { - return { - code: "payload_too_large:chat", - message: "本次请求内容过大,请减少图片或文字内容后重试。", - status: 413, - category: "payload_too_large", - detailMessage, - }; - } - - if ( - error instanceof OperationTimeoutError || - normalized.includes("timeout") || - normalized.includes("timed out") || - normalized.includes("body timeout") || - normalized.includes("aborterror") || - normalized.includes("aborted") - ) { - return { - code: "timeout:chat", - message: "当前模型响应超时,请重试,或切换到其他可用模型。", - status: 504, - category: "timeout", - detailMessage, - }; - } - - if ( - normalized.includes("fetch failed") || - normalized.includes("econnreset") || - normalized.includes("econnrefused") || - normalized.includes("enotfound") || - normalized.includes("etimedout") || - normalized.includes("und_err") || - normalized.includes("socket hang up") - ) { - return { - code: "server_error:chat", - message: "模型服务连接失败,请稍后重试,或切换其他可用模型。", - status: 502, - category: "upstream_connection", - detailMessage, - }; - } - - if ( - normalized.includes("database") || - normalized.includes("postgres") || - normalized.includes("drizzle") - ) { - return { - code: "server_error:chat", - message: "服务数据库暂时繁忙,请稍后重试。", - status: 503, - category: "database", - detailMessage, - }; - } - - return { - code: "server_error:chat", - message: "服务临时繁忙,请稍后重试;如果连续出现,请联系管理员。", - status: 500, - category: "unknown", - detailMessage, - }; -} - -function createChatApiFailureResponse(error: unknown, vercelId: string | null) { - const failure = resolveChatApiFailure(error); - const traceId = generateUUID(); - - console.error("[Chat API] Unhandled request failure:", { - traceId, - vercelId, - category: failure.category, - status: failure.status, - detailMessage: failure.detailMessage, - error, - }); - - return NextResponse.json( - { - code: failure.code, - message: failure.message, - cause: `trace:${traceId}`, - }, - { status: failure.status } - ); -} - -function normalizeAssistantPartsForPersistence(parts: Array> | undefined) { - const safeParts = Array.isArray(parts) ? parts.filter(Boolean) : []; - return safeParts.filter((part) => { - const partType = String(part?.type || "").trim(); - if (!partType) return false; - if (partType === "data-keepalive" || partType === "step-start" || partType === "step-finish") { - return false; - } - return true; - }); -} - -function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { - return buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ) as ArrayBuffer; -} - async function createAgentImageThumbnailBuffer(buffer: Buffer): Promise { const sharp = (await import("sharp")).default; return sharp(buffer, { failOn: "none" }) @@ -4226,56 +4089,6 @@ async function findChatWithRetry(chatId: string) { return null; } -class OperationTimeoutError extends Error { - constructor(operation: string, timeoutMs: number) { - super(`${operation} timed out after ${timeoutMs}ms`); - this.name = "OperationTimeoutError"; - } -} - -async function withOperationTimeout( - operation: Promise, - operationName: string, - timeoutMs: number, -): Promise { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - operation, - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new OperationTimeoutError(operationName, timeoutMs)), - timeoutMs, - ); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -function getRemainingBudgetMs(startedAt: number, budgetMs: number) { - return Math.max(0, budgetMs - (Date.now() - startedAt)); -} - -function getBudgetedTimeout(params: { - startedAt: number; - preferredMs: number; - minimumMs?: number; -}) { - const minimumMs = params.minimumMs ?? 6_000; - const remainingMs = getRemainingBudgetMs( - params.startedAt, - CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS - ); - if (remainingMs < minimumMs) { - return null; - } - return Math.min(params.preferredMs, remainingMs); -} - const getTokenlensCatalog = cache( async (): Promise => { try {