fix: normalize rate limit errors across chat and PSD flows
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 22:49:26 +08:00
parent 03f07564e0
commit 411bb4a5eb
8 changed files with 469 additions and 53 deletions

View File

@ -139,6 +139,11 @@ import type {
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import { ChatSDKError } from "@/lib/errors";
import {
getFriendlyRateLimitMessage,
getRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
import { extractImageUrlsFromText } from "@/lib/image-url";
import {
resolveClarificationBypass,
@ -12637,6 +12642,28 @@ export async function POST(request: Request) {
return error.toResponse();
}
if (isRateLimitError(error)) {
const detailMessage = getRequestErrorMessage(error);
console.warn("[Chat API] Upstream rate limited request:", {
vercelId,
error: detailMessage,
});
return NextResponse.json(
{
code: "rate_limit:chat",
message: getFriendlyRateLimitMessage(),
cause: detailMessage || "upstream_rate_limited",
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
}
);
}
// Check for Vercel AI Gateway credit card error
if (
error instanceof Error &&

View File

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
import {
executeExtendJob,
ExtendServiceError,
} from "@/lib/ai/extend/service";
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
export const maxDuration = 180;
@ -89,9 +91,25 @@ export async function POST(request: NextRequest) {
);
} catch (error) {
if (error instanceof ExtendServiceError) {
if (error.status === 429 || isRateLimitError(error)) {
return NextResponse.json(
{
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
detail: error.detail || error.message,
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
},
);
}
return NextResponse.json(
{
error: error.message,
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
detail: error.detail || error.message,
},
{ status: error.status },
@ -99,9 +117,24 @@ export async function POST(request: NextRequest) {
}
console.error("[Extend API] 错误:", error);
if (isRateLimitError(error)) {
return NextResponse.json(
{
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
detail: error instanceof Error ? error.message : String(error),
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
},
);
}
return NextResponse.json(
{
error: "图像拓展失败",
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
detail: error instanceof Error ? error.message : String(error),
},
{ status: 500 },

View File

@ -1,22 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getEditableAgentPsdTemplateLayers,
getAgentPsdTemplateRuntimeIssue,
isAgentPsdImageLayerSlot,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTemplateReady,
isAgentPsdTextLayerSlot,
type AgentPsdTemplateRenderResponse,
} from "@/lib/agents/psd-template-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
import { describeRemoteAssetLocation } from "@/lib/agents/psd-remote-asset";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import {
type AgentPsdTemplateRenderResponse,
getAgentPsdTemplateRuntimeIssue,
getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
export const runtime = "nodejs";
@ -163,10 +168,26 @@ export async function POST(request: NextRequest) {
assignedImageCount: Object.keys(body.imageAssignments || {}).length,
error,
});
if (isRateLimitError(error)) {
return NextResponse.json(
{
success: false,
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
} satisfies AgentPsdTemplateRenderResponse,
{
status: 429,
headers: {
"Retry-After": "10",
},
}
);
}
const message =
error instanceof Error && String(error.message || "").trim()
? error.message
: "PSD 模板渲染失败";
getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", {
rateLimitMessage: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
});
return jsonError(502, message);
}
}

View File

@ -81,6 +81,7 @@ import {
resolveTextLayout,
} from "@/lib/agents/psd-text-layout";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error";
function buildTemplateListRequestUrl(params?: {
brandName?: string | null;
@ -182,6 +183,55 @@ const templateDetailFetcher = async (
}
};
async function parseActionJsonResponse<T>(
response: Response,
fallbackMessage: string,
rateLimitMessage?: string
): Promise<T> {
const payload = await response
.clone()
.json()
.catch(() => null);
if (!response.ok) {
let responseText = "";
if (payload == null) {
responseText = await response
.text()
.then((value) => value.trim())
.catch(() => "");
}
const requestError = new Error(
getUserFacingRequestErrorMessage(
{
status: response.status,
data: payload,
message: responseText,
},
fallbackMessage,
{
rateLimitMessage,
}
)
) as Error & {
status?: number;
data?: unknown;
};
requestError.status = response.status;
requestError.data =
payload ??
(responseText
? {
message: responseText,
}
: null);
throw requestError;
}
return payload as T;
}
const PREVIEW_VIEWPORT_PADDING = 56;
const PREVIEW_STAGE_MAX_SCALE = 0.82;
const PREVIEW_STAGE_TARGET_WIDTH = 760;
@ -1946,15 +1996,20 @@ export function AgentPsdTemplateEditorDialog({
const formData = new FormData();
formData.set("file", file);
const response = await fetchUploadWithRetry(formData);
const payload = await response.json();
const payload = await parseActionJsonResponse<{ url?: string }>(
response,
"素材上传失败",
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
);
if (!response.ok || !payload.url) {
throw new Error(payload.error || "图片上传失败");
if (!payload?.url) {
throw new Error("图片上传失败");
}
const uploadedImageUrl = payload.url;
setImageAssignments((current) => ({
...current,
[slotId]: payload.url,
[slotId]: uploadedImageUrl,
}));
setImagePlacements((current) => ({
...current,
@ -1967,7 +2022,11 @@ export function AgentPsdTemplateEditorDialog({
});
toast.success("替换图片已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "素材上传失败");
toast.error(
getUserFacingRequestErrorMessage(error, "素材上传失败", {
rateLimitMessage: getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试"),
})
);
} finally {
setUploadingSlotId(null);
event.target.value = "";
@ -1993,13 +2052,14 @@ export function AgentPsdTemplateEditorDialog({
prompt: "PSD 套版图片拓展处理",
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
String(payload?.error || payload?.detail || "").trim() ||
"图片拓展失败"
);
}
const payload = await parseActionJsonResponse<{
original_url?: string;
thumbnail_url?: string;
}>(
response,
"图片拓展失败",
getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试")
);
const nextImageUrl = String(
payload?.original_url || payload?.thumbnail_url || ""
@ -2028,7 +2088,11 @@ export function AgentPsdTemplateEditorDialog({
: "图片拓展完成,已替换当前槽位"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片拓展失败");
toast.error(
getUserFacingRequestErrorMessage(error, "图片拓展失败", {
rateLimitMessage: getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试"),
})
);
} finally {
setExtendingSlotId(null);
}
@ -2154,9 +2218,13 @@ export function AgentPsdTemplateEditorDialog({
}),
});
const payload = (await response.json()) as AgentPsdTemplateRenderResponse;
if (!response.ok || !payload.success || !payload.result?.url) {
throw new Error(payload.error || "PSD 模板渲染失败");
const payload = await parseActionJsonResponse<AgentPsdTemplateRenderResponse>(
response,
"PSD 模板渲染失败",
getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试")
);
if (!payload?.success || !payload.result?.url) {
throw new Error(payload?.error || "PSD 模板渲染失败");
}
setRenderedResultUrl(payload.result.url);
@ -2168,7 +2236,11 @@ export function AgentPsdTemplateEditorDialog({
: "模板替换结果已生成"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
toast.error(
getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", {
rateLimitMessage: getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试"),
})
);
} finally {
setIsRendering(false);
}

View File

@ -33,6 +33,7 @@ import {
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability";
import { getFriendlyRateLimitMessage, isRateLimitError, getUserFacingRequestErrorMessage } from "@/lib/request-error";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "@/components/artifact";
import { ProtocolDebugPanel } from "@/components/protocol-debug-panel";
@ -373,6 +374,7 @@ export function Chat({
const imagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestAssistantDisplayTextRef = useRef<string>("");
const startedGenerationMessageIdRef = useRef<string | null>(null);
const lastRateLimitToastAtRef = useRef(0);
// 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突
const [appendedMessages, setAppendedMessages] = useState<ChatMessage[]>([]);
@ -948,6 +950,18 @@ export function Chat({
});
}
const normalizedErrorMessage = getUserFacingRequestErrorMessage(
{
...(error instanceof Error ? error : {}),
data: responseData,
message: fallbackMessage,
},
"请求失败,请稍后重试",
{
rateLimitMessage: getFriendlyRateLimitMessage(),
}
);
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if (
@ -955,10 +969,17 @@ export function Chat({
) {
setShowCreditCardAlert(true);
} else {
toast.error(error.message);
toast.error(normalizedErrorMessage);
}
} else {
toast.error(fallbackMessage || "请求失败,请稍后重试");
if (isRateLimitError({ ...(error as any), data: responseData, message: fallbackMessage })) {
const now = Date.now();
if (now - lastRateLimitToastAtRef.current < 4000) {
return;
}
lastRateLimitToastAtRef.current = now;
}
toast.error(normalizedErrorMessage);
}
},
});
@ -1459,7 +1480,11 @@ export function Chat({
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "变清晰失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前变清晰请求过于频繁,请稍后再试"),
})
);
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
@ -1479,7 +1504,11 @@ export function Chat({
} catch (error) {
console.error("[变清晰] 错误:", error);
console.log("[变清晰] 外层 catch 执行,重置 isProcessingEnhance");
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "变清晰失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前变清晰请求过于频繁,请稍后再试"),
})
);
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingEnhance(false);
}
@ -1604,7 +1633,11 @@ export function Chat({
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "抠图失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前抠图请求过于频繁,请稍后再试"),
})
);
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
@ -1624,7 +1657,11 @@ export function Chat({
} catch (error) {
console.error("[抠图] 错误:", error);
console.log("[抠图] 外层 catch 执行,重置 isProcessingRmbg");
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "抠图失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前抠图请求过于频繁,请稍后再试"),
})
);
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingRmbg(false);
}
@ -1715,7 +1752,11 @@ export function Chat({
}
} catch (error) {
console.error("[去水印] 错误:", error);
toast.error(error instanceof Error ? error.message : "去水印失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "去水印失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前去水印请求过于频繁,请稍后再试"),
})
);
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
@ -1844,7 +1885,11 @@ export function Chat({
}
} catch (error) {
console.error("[图像拓展] 错误:", error);
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "图像拓展失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前图像拓展请求过于频繁,请稍后再试"),
})
);
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
@ -1864,7 +1909,11 @@ export function Chat({
} catch (error) {
console.error("[图像拓展] 错误:", error);
console.log("[图像拓展] 外层 catch 执行,重置 isProcessingExtend");
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
toast.error(
getUserFacingRequestErrorMessage(error, "图像拓展失败,请稍后重试", {
rateLimitMessage: getFriendlyRateLimitMessage("当前图像拓展请求过于频繁,请稍后再试"),
})
);
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingExtend(false);
}

View File

@ -97,7 +97,7 @@ export function getMessageByErrorCode(errorCode: ErrorCode): string {
return "Your account does not have access to this feature.";
case "rate_limit:chat":
return "You have exceeded your maximum number of messages for the day. Please try again later.";
return "今日消息次数已达上限,请稍后再试。";
case "not_found:chat":
return "The requested chat was not found. Please check the chat ID and try again.";
case "forbidden:chat":

190
lib/request-error.ts Normal file
View File

@ -0,0 +1,190 @@
const RATE_LIMIT_PATTERNS = [
/\btoo many requests\b/i,
/\brate limit(?:ed|ing)?\b/i,
/\bquota exceeded\b/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;
}
export function isRateLimitError(error: unknown): boolean {
if (getRequestErrorStatus(error) === 429) {
return true;
}
const message = getRequestErrorMessage(error);
if (!message) {
return false;
}
return RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
}
export function getFriendlyRateLimitMessage(
customMessage = "当前请求过于频繁,请稍后再试"
) {
return (
normalizeTextCandidate(customMessage) || "当前请求过于频繁,请稍后再试"
);
}
export function getUserFacingRequestErrorMessage(
error: unknown,
fallbackMessage = "请求失败,请稍后重试",
options?: {
rateLimitMessage?: string;
}
) {
if (isRateLimitError(error)) {
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
}
const message = getRequestErrorMessage(error);
if (!message) {
return fallbackMessage;
}
if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message))) {
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
}
return message;
}

View File

@ -9,6 +9,7 @@ import { formatISO } from 'date-fns';
import { twMerge } from 'tailwind-merge';
import type { DBMessage, Document } from '@/lib/db/schema';
import { ChatSDKError, type ErrorCode } from './errors';
import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from './request-error';
import type { ChatMessage, ChatTools, CustomUIDataTypes } from './types';
export function cn(...inputs: ClassValue[]) {
@ -39,12 +40,21 @@ export async function fetchWithErrorHandlers(
if (!response.ok) {
let payload: any = null;
let responseText = "";
try {
payload = await response.json();
payload = await response.clone().json();
} catch {
payload = null;
}
if (payload == null) {
try {
responseText = (await response.text()).trim();
} catch {
responseText = "";
}
}
const code =
typeof payload?.code === "string" && payload.code.includes(":")
? (payload.code as ErrorCode)
@ -60,16 +70,30 @@ export async function fetchWithErrorHandlers(
}
const fallbackMessage =
(typeof payload?.message === "string" && payload.message) ||
(typeof payload?.error === "string" && payload.error) ||
`Request failed with status ${response.status}`;
getUserFacingRequestErrorMessage(
{
status: response.status,
data: payload,
message: responseText,
},
`Request failed with status ${response.status}`,
{
rateLimitMessage: getFriendlyRateLimitMessage(),
}
);
const requestError = new Error(fallbackMessage) as Error & {
status?: number;
data?: any;
};
requestError.name = "RequestError";
requestError.status = response.status;
requestError.data = payload;
requestError.data =
payload ??
(responseText
? {
message: responseText,
}
: null);
throw requestError;
}