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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user