const DEFAULT_MAX_RETRIES = 3; const MAX_BACKOFF_MS = 8000; const MAX_CONCURRENT_UPLOADS = 3; 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 { if (!value) return null; const seconds = Number(value); if (Number.isFinite(seconds) && seconds > 0) { return Math.round(seconds * 1000); } return null; } function computeBackoffMs(attempt: number, retryAfterMs: number | null) { if (retryAfterMs && retryAfterMs > 0) { return Math.min(retryAfterMs, MAX_BACKOFF_MS); } const exp = Math.min(MAX_BACKOFF_MS, 500 * 2 ** attempt); const jitter = Math.floor(Math.random() * 300); 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?: { maxRetries?: number; signal?: AbortSignal; } ) { const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; return withUploadSlot(async () => { for (let attempt = 0; ; attempt++) { try { const response = await fetch("/api/files/upload", { method: "POST", body: cloneFormData(formData), signal: options?.signal, }); 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); } } }); }