fix(volcano): retry image generation with inline base64 on remote fetch timeout
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-02-19 04:30:33 +08:00
parent 998094636c
commit bfec61aad1

View File

@ -15,6 +15,83 @@ type VolcanoUrlRewriteConfig = {
};
let cachedRewriteConfig: VolcanoUrlRewriteConfig | null | undefined;
const VOLCANO_IMAGE_FETCH_TIMEOUT_MS = 20_000;
const VOLCANO_INLINE_MAX_SIDE = 1024;
function guessImageMimeTypeFromUrl(url: string): string {
const cleanUrl = url.split("?")[0]?.toLowerCase() || "";
if (cleanUrl.endsWith(".png")) return "image/png";
if (cleanUrl.endsWith(".webp")) return "image/webp";
if (cleanUrl.endsWith(".gif")) return "image/gif";
if (cleanUrl.endsWith(".bmp")) return "image/bmp";
return "image/jpeg";
}
function shouldRetryVolcanoWithInlineImage(errorText: string): boolean {
if (!errorText) return false;
return /timeout while downloading url|download multimodal file timed out|invalidparameter/i.test(
errorText
);
}
async function fetchImageAsDataUri(url: string): Promise<string | null> {
if (!/^https?:\/\//i.test(url)) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), VOLCANO_IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
console.warn("[Volcano Vision] Failed to fetch source image for inline retry:", response.status, url);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length === 0) return null;
const inputMime = response.headers.get("content-type") || guessImageMimeTypeFromUrl(url);
try {
const sharp = await import("sharp");
const optimized = await sharp.default(buffer)
.rotate()
.resize(VOLCANO_INLINE_MAX_SIDE, VOLCANO_INLINE_MAX_SIDE, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 86, mozjpeg: true })
.toBuffer();
return `data:image/jpeg;base64,${optimized.toString("base64")}`;
} catch {
return `data:${inputMime};base64,${buffer.toString("base64")}`;
}
} catch (error) {
console.warn("[Volcano Vision] Inline image fetch failed:", error);
return null;
} finally {
clearTimeout(timeoutId);
}
}
async function convertImageFieldToInlineDataUri(
image: string | string[] | undefined
): Promise<string | string[] | undefined> {
if (!image) return image;
const list = Array.isArray(image) ? image : [image];
const converted = await Promise.all(
list.map(async (item) => {
if (typeof item !== "string") return null;
if (item.startsWith("data:image")) return item;
return fetchImageAsDataUri(item);
})
);
const ready = converted.filter((item): item is string => typeof item === "string" && item.length > 0);
if (ready.length === 0) return undefined;
return Array.isArray(image) ? ready : ready[0];
}
async function getVolcanoUrlRewriteConfig(): Promise<VolcanoUrlRewriteConfig | null> {
if (cachedRewriteConfig !== undefined) {
@ -426,49 +503,66 @@ async function sendVolcanoRequest(
}
}
// 设置 10 分钟超时
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 600000);
const executeRequest = async (payload: any, tag: string): Promise<VolcanoVisionResponse> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 600000);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text();
console.error(`[Volcano Vision] Request failed (${tag}):`, response.status, errorText);
const err = new Error(
`Volcano Vision API error: ${response.status} ${response.statusText} - ${errorText}`
) as Error & { status?: number; body?: string };
err.status = response.status;
err.body = errorText;
throw err;
}
const data = await response.json();
console.log(
`[Volcano Vision] Response received (${tag}):`,
JSON.stringify(data).substring(0, 200) + "..."
);
if (data.error) {
throw new Error(`API Error: ${data.error.message || data.error.code}`);
}
return data as VolcanoVisionResponse;
} finally {
clearTimeout(timeoutId);
}
};
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error("[Volcano Vision] Request failed:", response.status, errorText);
throw new Error(
`Volcano Vision API error: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
console.log(
"[Volcano Vision] Response received:",
JSON.stringify(data).substring(0, 200) + "..."
);
// 检查是否有错误
if (data.error) {
throw new Error(`API Error: ${data.error.message || data.error.code}`);
}
return data as VolcanoVisionResponse;
return await executeRequest(requestBody, "primary");
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
console.error("[Volcano Vision] Request timeout after 10 minutes");
throw new Error("请求超时,图片处理时间过长,请稍后重试");
}
const errorBody = (error as any)?.body || String(error || "");
if (requestBody.image && shouldRetryVolcanoWithInlineImage(errorBody)) {
console.warn("[Volcano Vision] Retrying with inline base64 image payload...");
const inlineImage = await convertImageFieldToInlineDataUri(requestBody.image);
if (inlineImage) {
const retryBody = { ...requestBody, image: inlineImage };
return await executeRequest(retryBody, "inline-retry");
}
console.warn("[Volcano Vision] Inline retry skipped: failed to convert source image(s).");
}
throw error;
}
}