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;
}
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(
systemPrompt: string,
userContent: string,
options?: { model?: string; signal?: AbortSignal },
): Promise<string> {
return retryOnTransient(async () => {
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(60000);
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
@@ -138,6 +164,7 @@ async function chat(
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
}
async function visionChat(
@@ -156,11 +183,12 @@ async function visionChat(
];
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
const timeoutSignal = AbortSignal.timeout(60000);
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
try {
const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
@@ -169,15 +197,18 @@ async function visionChat(
});
if (!res.ok) {
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})`);
}
const payload = await res.json();
const out: string =
const result: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!out) throw new Error("图片理解未返回有效内容");
if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, signal);
return out;
} catch (err) {
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
throw err;
}
+3 -1
View File
@@ -408,9 +408,11 @@ export const aiGenerationClient = {
form.append("file", blob, options?.name || "upload.png");
if (options?.scope) form.append("scope", options.scope);
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"), {
method: "POST",
headers: { ...buildAuthHeaders() },
headers: authHeaders,
body: form,
});
if (!res.ok) {