refactor: extract chat route error helpers
This commit is contained in:
parent
c42dc88ba1
commit
33bfcadf97
188
app/(chat)/api/chat/_lib/errors.ts
Normal file
188
app/(chat)/api/chat/_lib/errors.ts
Normal 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);
|
||||
}
|
||||
24
app/(chat)/api/chat/_lib/persistence.ts
Normal file
24
app/(chat)/api/chat/_lib/persistence.ts
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user