fix: harden image upload reliability
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-05-27 09:59:45 +08:00
parent 13a6e3df83
commit 935f07d6ec
5 changed files with 143 additions and 25 deletions

View File

@ -6191,6 +6191,7 @@ export function CanvasChatPanel({
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
@ -6263,6 +6264,7 @@ export function CanvasChatPanel({
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
@ -6435,6 +6437,7 @@ export function CanvasChatPanel({
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
@ -8801,6 +8804,7 @@ export function CanvasChatPanel({
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('purpose', 'canvas-upload');
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
@ -9052,6 +9056,7 @@ export function CanvasChatPanel({
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('purpose', 'canvas-upload');
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);

View File

@ -1087,6 +1087,7 @@ async function uploadChatAttachmentBlob(blob: Blob, displayName: string) {
const formData = new FormData(); const formData = new FormData();
const contentType = uploadBlob.type || "image/jpeg"; const contentType = uploadBlob.type || "image/jpeg";
formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType)); formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType));
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
let payload: any = null; let payload: any = null;
@ -6370,6 +6371,7 @@ export function ImageNode({
const blob = await response.blob(); const blob = await response.blob();
const formData = new FormData(); const formData = new FormData();
formData.append("file", blob, `annotated-${Date.now()}.png`); formData.append("file", blob, `annotated-${Date.now()}.png`);
formData.append("purpose", "canvas-upload");
const uploadResponse = await fetchUploadWithRetry(formData); const uploadResponse = await fetchUploadWithRetry(formData);
if (uploadResponse.ok) { if (uploadResponse.ok) {
const result = await uploadResponse.json(); const result = await uploadResponse.json();
@ -6406,6 +6408,7 @@ export function ImageNode({
const blob = await response.blob(); const blob = await response.blob();
const formData = new FormData(); const formData = new FormData();
formData.append("file", blob, `canvas-resize-${Date.now()}.png`); formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
formData.append("purpose", "canvas-upload");
const uploadResponse = await fetchUploadWithRetry(formData); const uploadResponse = await fetchUploadWithRetry(formData);

View File

@ -138,6 +138,7 @@ export function TextNode({
const dimensions = await getPastedImageDimensions(file); const dimensions = await getPastedImageDimensions(file);
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("purpose", "canvas-upload");
try { try {
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);

View File

@ -604,6 +604,7 @@ function getFlowBoardImageDimensions(file: File) {
async function uploadFlowBoardImageFile(file: File) { async function uploadFlowBoardImageFile(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
if (!response.ok) { if (!response.ok) {

View File

@ -1,8 +1,70 @@
const DEFAULT_MAX_RETRIES = 3; const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000; const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3;
function sleep(ms: number) { const RETRYABLE_UPLOAD_STATUSES = new Set([
return new Promise((resolve) => setTimeout(resolve, ms)); 408,
425,
429,
500,
502,
503,
504,
]);
let activeUploadCount = 0;
const uploadQueue: Array<() => void> = [];
function createAbortError() {
const error = new Error("Upload aborted");
error.name = "AbortError";
return error;
}
function sleep(ms: number, signal?: AbortSignal) {
if (signal?.aborted) {
return Promise.reject(createAbortError());
}
return new Promise<void>((resolve, reject) => {
const timeout = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
globalThis.clearTimeout(timeout);
reject(createAbortError());
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function acquireUploadSlot() {
return new Promise<void>((resolve) => {
const start = () => {
activeUploadCount += 1;
resolve();
};
if (activeUploadCount < MAX_CONCURRENT_UPLOADS) {
start();
} else {
uploadQueue.push(start);
}
});
}
async function withUploadSlot<T>(task: () => Promise<T>): Promise<T> {
await acquireUploadSlot();
try {
return await task();
} finally {
activeUploadCount = Math.max(0, activeUploadCount - 1);
uploadQueue.shift()?.();
}
} }
function parseRetryAfterMs(value: string | null): number | null { function parseRetryAfterMs(value: string | null): number | null {
@ -23,6 +85,41 @@ function computeBackoffMs(attempt: number, retryAfterMs: number | null) {
return exp + jitter; return exp + jitter;
} }
function isRetryableUploadStatus(status: number) {
return RETRYABLE_UPLOAD_STATUSES.has(status);
}
function isAbortError(error: unknown, signal?: AbortSignal) {
return (
signal?.aborted ||
(error instanceof Error && error.name === "AbortError")
);
}
function cloneFormData(formData: FormData) {
const copy = new FormData();
formData.forEach((value, key) => {
if (typeof value === "string") {
copy.append(key, value);
return;
}
const filename =
typeof File !== "undefined" && value instanceof File
? value.name
: undefined;
if (filename) {
copy.append(key, value, filename);
} else {
copy.append(key, value);
}
});
return copy;
}
export async function fetchUploadWithRetry( export async function fetchUploadWithRetry(
formData: FormData, formData: FormData,
options?: { options?: {
@ -32,29 +129,40 @@ export async function fetchUploadWithRetry(
) { ) {
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
for (let attempt = 0; ; attempt++) { return withUploadSlot(async () => {
const response = await fetch("/api/files/upload", { for (let attempt = 0; ; attempt++) {
method: "POST",
body: formData,
signal: options?.signal,
});
if (response.status !== 429 || attempt >= maxRetries) {
return response;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try { try {
const payload = await response.clone().json(); const response = await fetch("/api/files/upload", {
const retryAfterSec = Number(payload?.retryAfter || 0); method: "POST",
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) { body: cloneFormData(formData),
retryAfterMs = Math.round(retryAfterSec * 1000); signal: options?.signal,
} });
} catch {}
}
const delayMs = computeBackoffMs(attempt, retryAfterMs); if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
await sleep(delayMs); return response;
} }
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
const delayMs = computeBackoffMs(attempt, retryAfterMs);
await sleep(delayMs, options?.signal);
} catch (error) {
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
throw error;
}
const delayMs = computeBackoffMs(attempt, null);
await sleep(delayMs, options?.signal);
}
}
});
} }