fix: upload-binary Content-Type fix, 429/timeout retry, 120s timeout - Remove Content-Type: application/json from uploadAssetBinary FormData request - Add retryOnTransient for 429 + timeout + signal timed out errors - Increase AI chat timeout from 60s to 120s per call - Apply retry logic to both chat() and visionChat()

This commit is contained in:
2026-06-02 16:58:59 +08:00
parent 9504f8ee87
commit 94c1453c9b
2 changed files with 75 additions and 42 deletions
+36 -5
View File
@@ -108,16 +108,42 @@ interface ChatMessage {
content: string; content: string;
} }
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000;
const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout");
}
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (err) {
if (signal?.aborted) throw err;
if (attempt === MAX_RETRIES) throw err;
if (!isTransientError(err)) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("unreachable");
}
async function chat( async function chat(
systemPrompt: string, systemPrompt: string,
userContent: string, userContent: string,
options?: { model?: string; signal?: AbortSignal }, options?: { model?: string; signal?: AbortSignal },
): Promise<string> { ): Promise<string> {
return retryOnTransient(async () => {
const messages: ChatMessage[] = [ const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
{ role: "user", content: userContent }, { role: "user", content: userContent },
]; ];
const timeoutSignal = AbortSignal.timeout(60000); const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal]) ? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal; : timeoutSignal;
@@ -138,6 +164,7 @@ async function chat(
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
return content; return content;
}, options?.signal);
} }
async function visionChat( async function visionChat(
@@ -156,11 +183,12 @@ async function visionChat(
]; ];
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
const timeoutSignal = AbortSignal.timeout(60000); const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal]) ? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal; : timeoutSignal;
try { try {
const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: buildAuthHeaders(),
@@ -169,15 +197,18 @@ async function visionChat(
}); });
if (!res.ok) { if (!res.ok) {
const errBody = await res.text().catch(() => ""); const errBody = await res.text().catch(() => "");
if (model === VISION_MODEL && errBody.includes("image format")) continue; if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})`); throw new Error(`图片理解调用失败 (${res.status})`);
} }
const payload = await res.json(); const payload = await res.json();
const out: string = const result: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!out) throw new Error("图片理解未返回有效内容"); if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, signal);
return out; return out;
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
throw err; throw err;
} }
+3 -1
View File
@@ -408,9 +408,11 @@ export const aiGenerationClient = {
form.append("file", blob, options?.name || "upload.png"); form.append("file", blob, options?.name || "upload.png");
if (options?.scope) form.append("scope", options.scope); if (options?.scope) form.append("scope", options.scope);
if (options?.mimeType) form.append("mimeType", options.mimeType); if (options?.mimeType) form.append("mimeType", options.mimeType);
// Exclude Content-Type so browser auto-sets multipart/form-data with boundary
const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders();
const res = await fetch(buildApiUrl("oss/upload-binary"), { const res = await fetch(buildApiUrl("oss/upload-binary"), {
method: "POST", method: "POST",
headers: { ...buildAuthHeaders() }, headers: authHeaders,
body: form, body: form,
}); });
if (!res.ok) { if (!res.ok) {