ai-chatbot/lib/request-error.ts

322 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const RATE_LIMIT_PATTERNS = [
/\btoo many requests\b/i,
/\brate limit(?:ed|ing)?\b/i,
/\bquota exceeded\b/i,
/请求过于频繁/,
/请求太频繁/,
/访问过于频繁/,
/频率过高/,
/达到速率限制/,
/限流/,
];
const PROVIDER_BALANCE_PATTERNS = [
/余额(?:不足|不够|已?耗尽)/i,
/额度(?:不足|不够|已?耗尽)/i,
/预扣费额度(?:不足|失败)/i,
/\binsufficient[ _-]?(?:balance|credits?|funds?|quota)\b/i,
/\b(?:balance|credits?|funds?)\s+(?:is\s+)?(?:not enough|insufficient|exhausted)\b/i,
/\b(?:exceeded_current_quota|pre_consume_token_quota_failed)\b/i,
/\btoken quota is not enough\b/i,
/\bquota is not enough\b/i,
/\b(?:not enough credits?|credits? is not enough)\b/i,
/\b(?:billing_hard_limit_reached|payment_required)\b/i,
];
const NETWORK_TRANSPORT_PATTERNS = [
/\bfailed to fetch\b/i,
/\bnetworkerror\b/i,
/\bload failed\b/i,
/network request failed/i,
/having trouble sending your message/i,
/check your internet connection/i,
];
const JSON_EMBEDDED_MESSAGE_PATTERN = /^[["{]/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function normalizeTextCandidate(value: string) {
return value.trim().replace(/^["']|["']$/g, "");
}
function collectTextCandidates(
value: unknown,
results: string[],
seen = new WeakSet<object>(),
depth = 0
) {
if (depth > 4 || value == null) {
return;
}
if (typeof value === "string") {
const normalized = normalizeTextCandidate(value);
if (normalized) {
results.push(normalized);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectTextCandidates(item, results, seen, depth + 1);
}
return;
}
if (!isRecord(value)) {
return;
}
if (seen.has(value)) {
return;
}
seen.add(value);
const preferredKeys = [
"message",
"error",
"detail",
"cause",
"statusText",
"responseText",
"body",
"data",
];
for (const key of preferredKeys) {
if (key in value) {
collectTextCandidates(value[key], results, seen, depth + 1);
}
}
for (const [key, candidate] of Object.entries(value)) {
if (preferredKeys.includes(key)) {
continue;
}
collectTextCandidates(candidate, results, seen, depth + 1);
}
}
function parseJsonEmbeddedMessage(value: string): string | null {
const normalized = normalizeTextCandidate(value);
if (!normalized || !JSON_EMBEDDED_MESSAGE_PATTERN.test(normalized)) {
return null;
}
try {
const parsed = JSON.parse(normalized);
const messages: string[] = [];
collectTextCandidates(parsed, messages);
return messages.find(Boolean) || null;
} catch {
return null;
}
}
export function getRequestErrorStatus(error: unknown): number | null {
const candidates: unknown[] = [];
if (isRecord(error)) {
candidates.push(
error.status,
error.statusCode,
isRecord(error.response) ? error.response.status : null,
isRecord(error.cause) ? error.cause.status : null,
isRecord(error.data) ? error.data.status : null,
isRecord(error.data) ? error.data.statusCode : null
);
}
for (const candidate of candidates) {
const numeric = Number(candidate);
if (Number.isFinite(numeric) && numeric >= 100) {
return Math.round(numeric);
}
}
return null;
}
export function getRequestErrorMessage(error: unknown): string | null {
const candidates: string[] = [];
if (error instanceof Error) {
collectTextCandidates(
{
message: error.message,
cause: error.cause,
...(isRecord(error) ? error : {}),
},
candidates
);
} else {
collectTextCandidates(error, candidates);
}
for (const candidate of candidates) {
const embeddedMessage = parseJsonEmbeddedMessage(candidate);
const normalized = normalizeTextCandidate(embeddedMessage || candidate);
if (normalized && normalized !== "[object Object]") {
return normalized;
}
}
return null;
}
function getRequestErrorText(error: unknown) {
const candidates: string[] = [];
if (error instanceof Error) {
collectTextCandidates({ ...error, message: error.message, cause: error.cause }, candidates);
} else {
collectTextCandidates(error, candidates);
}
return candidates.join(" ").slice(0, 4000);
}
export function getGatewayErrorPageMessage(content: string): string | null {
if (!/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(content)) return null;
if (!/\bcf-error-details\b/i.test(content)) return null;
if (/\b524\b/.test(content)) {
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
}
return "模型服务暂时不可用,请稍后再试或联系管理员。";
}
export function isGatewayResponseTimeout(error: unknown): boolean {
return getRequestErrorStatus(error) === 524 ||
/\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error));
}
export function isProviderBalanceError(error: unknown): boolean {
const message = getRequestErrorText(error);
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
}
export function isRateLimitError(error: unknown): boolean {
// Some providers return HTTP 429 for an actual unpaid balance. The status
// alone must not override an explicit billing error in the response body.
if (isProviderBalanceError(error)) {
return false;
}
if (getRequestErrorStatus(error) === 429) {
return true;
}
const message = getRequestErrorText(error);
if (!message) {
return false;
}
return /(?:^|\D)429(?:\D|$)/.test(message) ||
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
}
export type RequestFailureDetails = {
code: string;
message: string;
recommendation: string;
outcome: "failed" | "unknown";
};
export function getRequestFailureDetails(
error: unknown,
fallbackMessage = "请求未能完成",
): RequestFailureDetails {
const text = getRequestErrorText(error);
const status = getRequestErrorStatus(error);
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
({ code, message, recommendation, outcome });
if (isGatewayResponseTimeout(error)) {
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
}
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
}
if (isProviderBalanceError(error)) {
return detail("PROVIDER_BALANCE_EXHAUSTED", "图片服务商线路余额或额度不足,与您的积分余额无关。", "请切换可用模型或联系管理员处理线路额度。");
}
if (isRateLimitError(error)) {
return detail("RATE_LIMITED", "当前请求过于频繁。", "请稍后重试,避免连续点击;模型线路限流时可切换其他可用模型。");
}
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
}
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
}
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
}
if (/版本冲突|画布.*(?:更新|过期)|CANVAS_REVISION_CONFLICT|stale.canvas/i.test(text)) {
return detail("CANVAS_REVISION_CONFLICT", "画布已更新,本次保存与当前版本冲突。", "请先重新同步画布,再继续操作,避免覆盖较新的修改。");
}
if (/no_eligible_route|service_unavailable|service_rejected|channel.*unavailable|暂无可用服务|未配置.*模型|没有可用.*(?:模型|通道)|通道.*(?:不可用|未通过)|鉴权/i.test(text)) {
return detail("PROVIDER_UNAVAILABLE", "当前模型服务或通道暂不可用。", "请切换其他可用模型;若多个模型都失败,请联系管理员检查服务配置。");
}
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
}
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
}
if (/timeout|timed out|超时|fetch failed|ECONNRESET|ECONNREFUSED|failed to fetch|networkerror|load failed|network request failed/i.test(text) || [502, 504].includes(status || 0)) {
return detail("RESULT_UNCONFIRMED", "请求连接中断或等待超时,任务结果尚未确认。", "请先查看原任务或历史记录,避免重复提交;持续没有结果时联系管理员。", "unknown");
}
if (/generate(?: image)? failed|generation failed|upstream_server_error|service_error|excessive system load|未返回可用图片|任务FAILED/i.test(text) || (status !== null && status >= 500)) {
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
}
const message = getRequestErrorMessage(error);
return detail("REQUEST_FAILED", message && !/^\s*</.test(message) ? message : fallbackMessage, "");
}
export function formatRequestFailure(details: RequestFailureDetails, requestId?: string) {
const text = [details.message, details.recommendation].filter(Boolean).join("");
return requestId ? `${text}\n\n请求编号:${requestId}` : text;
}
export function getUserFacingImageGenerationErrorMessage(
error: unknown,
fallbackMessage = "图片生成失败,请稍后重试",
) {
return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
}
export function getFriendlyRateLimitMessage(
customMessage = "当前请求过于频繁,请稍后再试"
) {
return (
normalizeTextCandidate(customMessage) || "当前请求过于频繁,请稍后再试"
);
}
export function getUserFacingRequestErrorMessage(
error: unknown,
fallbackMessage = "请求失败,请稍后重试",
options?: {
rateLimitMessage?: string;
}
) {
if (isRateLimitError(error)) {
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
}
const failure = getRequestFailureDetails(error, fallbackMessage);
if (failure.code !== "REQUEST_FAILED") return formatRequestFailure(failure);
const message = getRequestErrorMessage(error);
if (!message) {
return fallbackMessage;
}
if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message))) {
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
}
if (NETWORK_TRANSPORT_PATTERNS.some((pattern) => pattern.test(message))) {
return "请求发送失败,请检查网络或稍后重试。";
}
return message;
}