diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 301f99287..101e1c28f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -6191,6 +6191,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); @@ -6263,6 +6264,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); @@ -6435,6 +6437,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); @@ -8801,6 +8804,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append('file', file); + formData.append('purpose', 'canvas-upload'); const response = await fetchUploadWithRetry(formData); @@ -9052,6 +9056,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append('file', file); + formData.append('purpose', 'canvas-upload'); const response = await fetchUploadWithRetry(formData); diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 775d245b0..a9544e4f3 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1087,6 +1087,7 @@ async function uploadChatAttachmentBlob(blob: Blob, displayName: string) { const formData = new FormData(); const contentType = uploadBlob.type || "image/jpeg"; formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType)); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); let payload: any = null; @@ -6370,6 +6371,7 @@ export function ImageNode({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `annotated-${Date.now()}.png`); + formData.append("purpose", "canvas-upload"); const uploadResponse = await fetchUploadWithRetry(formData); if (uploadResponse.ok) { const result = await uploadResponse.json(); @@ -6406,6 +6408,7 @@ export function ImageNode({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `canvas-resize-${Date.now()}.png`); + formData.append("purpose", "canvas-upload"); const uploadResponse = await fetchUploadWithRetry(formData); diff --git a/components/canvas/nodes/TextNode.tsx b/components/canvas/nodes/TextNode.tsx index f403d14a1..ad690cf4e 100644 --- a/components/canvas/nodes/TextNode.tsx +++ b/components/canvas/nodes/TextNode.tsx @@ -138,6 +138,7 @@ export function TextNode({ const dimensions = await getPastedImageDimensions(file); const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); try { const response = await fetchUploadWithRetry(formData); diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index fe4ab032c..bfc7084de 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -604,6 +604,7 @@ function getFlowBoardImageDimensions(file: File) { async function uploadFlowBoardImageFile(file: File) { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); if (!response.ok) { diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index 482aa170d..39568a79e 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -1,8 +1,70 @@ const DEFAULT_MAX_RETRIES = 3; const MAX_BACKOFF_MS = 8000; +const MAX_CONCURRENT_UPLOADS = 3; -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); +const RETRYABLE_UPLOAD_STATUSES = new Set([ + 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((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((resolve) => { + const start = () => { + activeUploadCount += 1; + resolve(); + }; + + if (activeUploadCount < MAX_CONCURRENT_UPLOADS) { + start(); + } else { + uploadQueue.push(start); + } + }); +} + +async function withUploadSlot(task: () => Promise): Promise { + await acquireUploadSlot(); + + try { + return await task(); + } finally { + activeUploadCount = Math.max(0, activeUploadCount - 1); + uploadQueue.shift()?.(); + } } function parseRetryAfterMs(value: string | null): number | null { @@ -23,6 +85,41 @@ function computeBackoffMs(attempt: number, retryAfterMs: number | null) { 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( formData: FormData, options?: { @@ -32,29 +129,40 @@ export async function fetchUploadWithRetry( ) { const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; - for (let attempt = 0; ; attempt++) { - const response = await fetch("/api/files/upload", { - 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) { + return withUploadSlot(async () => { + for (let attempt = 0; ; attempt++) { 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 response = await fetch("/api/files/upload", { + method: "POST", + body: cloneFormData(formData), + signal: options?.signal, + }); - const delayMs = computeBackoffMs(attempt, retryAfterMs); - await sleep(delayMs); - } + if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) { + 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); + } + } + }); }