diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index ba19a1b61..b18bb82c6 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -7,6 +7,8 @@ const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; const DIRECT_UPLOAD_PART_CONCURRENCY = 4; const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000; const CLIENT_UPLOAD_ID_FIELD = "clientUploadId"; +const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 15 * 1000; +const DIRECT_UPLOAD_ABORT_TIMEOUT_MS = 8 * 1000; const XHR_UPLOAD_TOTAL_TIMEOUT_MS = 6 * 60 * 1000; const XHR_UPLOAD_STALL_TIMEOUT_MS = 30 * 1000; @@ -90,6 +92,12 @@ function createAbortError() { return error; } +function createTimeoutError(message: string) { + const error = new Error(message); + error.name = "TimeoutError"; + return error; +} + function sleep(ms: number, signal?: AbortSignal) { if (signal?.aborted) { return Promise.reject(createAbortError()); @@ -314,18 +322,75 @@ async function readErrorPayload(response: Response) { } } +async function fetchWithControlTimeout( + input: RequestInfo | URL, + init: RequestInit, + timeoutMs: number, + signal?: AbortSignal +) { + if (signal?.aborted) { + throw createAbortError(); + } + + const controller = new AbortController(); + let timeout: ReturnType | null = null; + let timedOut = false; + + const onAbort = () => { + controller.abort(); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + + try { + timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + return await fetch(input, { + ...init, + signal: controller.signal, + }); + } catch (error) { + if (signal?.aborted) { + throw createAbortError(); + } + + if (timedOut) { + throw createTimeoutError( + `Direct upload control request timed out after ${Math.round( + timeoutMs / 1000 + )}s` + ); + } + + throw error; + } finally { + if (timeout) { + clearTimeout(timeout); + } + signal?.removeEventListener("abort", onAbort); + } +} + async function postDirectUploadAction( payload: Record, - signal?: AbortSignal + signal?: AbortSignal, + timeoutMs = DIRECT_UPLOAD_ACTION_TIMEOUT_MS ): Promise { - const response = await fetch("/api/files/direct-upload", { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithControlTimeout( + "/api/files/direct-upload", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), }, - body: JSON.stringify(payload), - signal, - }); + timeoutMs, + signal + ); if (!response.ok) { throw new Error(String(await readErrorPayload(response))); @@ -745,7 +810,8 @@ async function fetchDirectUpload( uploadId: multipartUpload.uploadId, clientUploadId: file.clientUploadId, }, - signal + signal, + DIRECT_UPLOAD_ABORT_TIMEOUT_MS ).catch(() => {}); }