211 lines
5.7 KiB
TypeScript
211 lines
5.7 KiB
TypeScript
import { createLogger } from "@/lib/observability/logger";
|
|
import { NextResponse } from "next/server";
|
|
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
|
import { generateUUID } from "@/lib/utils";
|
|
|
|
const chatApiLogger = createLogger("chat.errors");
|
|
|
|
|
|
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 (isProviderBalanceError(error)) {
|
|
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
|
}
|
|
|
|
if (isRateLimitError(error)) {
|
|
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
|
|
}
|
|
|
|
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 (isProviderBalanceError(error)) {
|
|
return {
|
|
code: "server_error:chat",
|
|
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
|
|
status: 503,
|
|
category: "provider_balance",
|
|
detailMessage,
|
|
};
|
|
}
|
|
|
|
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();
|
|
|
|
chatApiLogger.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);
|
|
}
|