diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 43951786a..7975fb905 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -34,6 +34,11 @@ if (SENTRY_ENABLED) { }); } +/** Base delays in milliseconds before each retry attempt following the initial request. */ +const FETCH_RETRY_DELAYS_MS = [0, 5000, 15000]; +/** Random jitter added to each retry delay to avoid a thundering herd against a struggling server. */ +const FETCH_RETRY_JITTER_MS = 1000; + const originalFetch = window.fetch; window.fetch = (input, init) => { const url = @@ -53,9 +58,66 @@ window.fetch = (input, init) => { const sessionId = getSessionId(); const headers = new Headers(init?.headers); headers.set("Sendou-Session-Id", sessionId); - return originalFetch(input, { ...init, headers }); + + return fetchWithRetry(input, { ...init, headers }); }; +const isRetryableMethod = (method: string) => { + const normalized = method.toUpperCase(); + return normalized === "GET" || normalized === "HEAD"; +}; + +const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +async function fetchWithRetry( + input: RequestInfo | URL, + init: RequestInit, +): Promise { + const method = + init.method ?? (input instanceof Request ? input.method : "GET"); + + if (!isRetryableMethod(method)) { + return originalFetch(input, init); + } + + let lastError: unknown; + for (let attempt = 0; attempt <= FETCH_RETRY_DELAYS_MS.length; attempt++) { + if (attempt > 0) { + await wait( + FETCH_RETRY_DELAYS_MS[attempt - 1] + + Math.random() * FETCH_RETRY_JITTER_MS, + ); + } + + try { + const response = await originalFetch(input, init); + + // retry on server errors, but let the caller handle other statuses + if (response.status >= 500 && attempt < FETCH_RETRY_DELAYS_MS.length) { + continue; + } + + return response; + } catch (error) { + // a cancelled request (e.g. a superseded navigation) must fail fast, not retry + if ( + init.signal?.aborted || + (error instanceof DOMException && error.name === "AbortError") + ) { + throw error; + } + + lastError = error; + if (attempt === FETCH_RETRY_DELAYS_MS.length) { + throw error; + } + } + } + + throw lastError; +} + if ("serviceWorker" in navigator) { window.addEventListener("load", () => { // we will register it after the page complete the load