refactor: extract chat route error helpers

This commit is contained in:
Your Name 2026-05-26 22:15:49 +08:00
parent c42dc88ba1
commit 33bfcadf97
3 changed files with 223 additions and 198 deletions

View File

@ -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<T>(
operation: Promise<T>,
operationName: string,
timeoutMs: number
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
operation,
new Promise<never>((_, 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);
}

View File

@ -0,0 +1,24 @@
export function normalizeAssistantPartsForPersistence(
parts: Array<Record<string, any>> | 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;
}

View File

@ -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<Record<string, any>> | 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<Buffer> {
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<T>(
operation: Promise<T>,
operationName: string,
timeoutMs: number,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
operation,
new Promise<never>((_, 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<ModelCatalog | undefined> => {
try {