diff --git a/src/app/employee/documents/fetchWithRetries.ts b/src/app/employee/documents/fetchWithRetries.ts deleted file mode 100644 index b1d2f27..0000000 --- a/src/app/employee/documents/fetchWithRetries.ts +++ /dev/null @@ -1,57 +0,0 @@ -// 1) A helper function that retries fetch up to `maxRetries` times -export async function fetchWithRetries( - url: string, - options: RequestInit = {}, - maxRetries = 5 -) { - let lastError: unknown = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const res = await fetch(url, options); - - if (!res.ok) { - // For a non-200 response, you can parse the error body or just throw. - const rawErrorData: unknown = await res.json().catch(() => ({})); - - if (typeof rawErrorData !== "object") { - throw new Error(`Request failed with status ${res.status}`); - } - - const errorData = rawErrorData as { error?: string }; - - throw new Error(errorData.error ?? `Request failed with status ${res.status}`); - } - - // If fetch + response parsing is successful, return the JSON - const data: unknown = await res.json(); // Store in a variable - return data; // Then return the resolved value - } catch (err: unknown) { - lastError = err; - - // Check if it's specifically a "timeout" or "network" error - if (err instanceof Error) { - const isTimeoutError = - /timed out/i.test(err.message) || err.name === "AbortError"; - - if (isTimeoutError && attempt < maxRetries) { - console.warn(`Attempt ${attempt} failed due to timeout, retrying...`); - continue; // Go to the next attempt - } - - // If it's a non-timeout error or we've used all retries, re-throw - throw err; // This is safe now because `err` is an Error - } else { - // Wrap non-Error in a real Error - throw new Error(`Non-Error thrown: ${String(err)}`); - } - } - } - - // If we somehow exit the loop, throw the last error - // If `lastError` is not an Error, wrap it - if (!(lastError instanceof Error)) { - throw new Error(`Non-Error thrown: ${String(lastError)}`); - } - throw lastError; -} \ No newline at end of file diff --git a/src/app/employee/documents/page.tsx b/src/app/employee/documents/page.tsx index 70d146c..47d03ab 100644 --- a/src/app/employee/documents/page.tsx +++ b/src/app/employee/documents/page.tsx @@ -9,7 +9,7 @@ import styles from "../../../styles/Employee/DocumentViewer.module.css"; import LoadingDoc from "~/app/employee/documents/loading-doc"; import LoadingPage from "~/app/_components/loading"; -import { fetchWithRetries } from "./fetchWithRetries"; +import { fetchWithRetries } from "~/lib/fetchWithRetries"; import { DocumentsSidebar } from "./DocumentsSidebar"; import { DocumentContent } from "./DocumentContent"; import { type QAHistoryEntry } from "~/app/employer/documents/ChatHistory"; diff --git a/src/app/employer/documents/fetchWithRetries.ts b/src/app/employer/documents/fetchWithRetries.ts deleted file mode 100644 index b2ee214..0000000 --- a/src/app/employer/documents/fetchWithRetries.ts +++ /dev/null @@ -1,57 +0,0 @@ -// 1) A helper function that retries fetch up to `maxRetries` times -export async function fetchWithRetries( - url: string, - options: RequestInit = {}, - maxRetries = 5 -) { - let lastError: unknown = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const res = await fetch(url, options); - - if (!res.ok) { - // For a non-200 response, you can parse the error body or just throw. - const rawErrorData : unknown = await res.json().catch(() => ({})); - - if(typeof rawErrorData !== "object") { - throw new Error(`Request failed with status ${res.status}`); - } - - const errorData = rawErrorData as { error?: string }; - - throw new Error(errorData.error ?? `Request failed with status ${res.status}`); - } - - // If fetch + response parsing is successful, return the JSON - const data: unknown = await res.json(); // Store in a variable - return data; // Then return the resolved value - } catch (err: unknown) { - lastError = err; - - // Check if it's specifically a "timeout" or "network" error - if (err instanceof Error) { - const isTimeoutError = - /timed out/i.test(err.message) || err.name === "AbortError"; - - if (isTimeoutError && attempt < maxRetries) { - console.warn(`Attempt ${attempt} failed due to timeout, retrying...`); - continue; // Go to the next attempt - } - - // If it's a non-timeout error or we've used all retries, re-throw - throw err; // This is safe now because `err` is an Error - } else { - // Wrap non-Error in a real Error - throw new Error(`Non-Error thrown: ${String(err)}`); - } - } - } - - // If we somehow exit the loop, throw the last error - // If `lastError` is not an Error, wrap it - if (!(lastError instanceof Error)) { - throw new Error(`Non-Error thrown: ${String(lastError)}`); - } - throw lastError; -} \ No newline at end of file diff --git a/src/app/employer/documents/page.tsx b/src/app/employer/documents/page.tsx index f56b24a..8692d79 100644 --- a/src/app/employer/documents/page.tsx +++ b/src/app/employer/documents/page.tsx @@ -7,7 +7,7 @@ import { useAuth } from "@clerk/nextjs"; import styles from "~/styles/Employer/DocumentViewer.module.css"; import LoadingPage from "~/app/_components/loading"; -import { fetchWithRetries } from "./fetchWithRetries"; +import { fetchWithRetries } from "~/lib/fetchWithRetries"; import { DocumentsSidebar } from "./DocumentsSidebar"; import { DocumentContent } from "./DocumentContent"; import { type ViewMode, type errorType } from "~/app/employer/documents/types"; diff --git a/src/lib/fetchWithRetries.ts b/src/lib/fetchWithRetries.ts new file mode 100644 index 0000000..370fdac --- /dev/null +++ b/src/lib/fetchWithRetries.ts @@ -0,0 +1,167 @@ +/** + * Fetch utility with automatic retry support for timeout and network errors. + * + * Features: + * - Automatic retry on timeout/network errors + * - Configurable retry count and delay + * - Exponential backoff support + * - Proper error handling and type safety + */ + +export interface FetchWithRetriesOptions { + /** + * Maximum number of retry attempts + * @default 5 + */ + maxRetries?: number; + + /** + * Base delay in milliseconds between retries + * @default 1000 + */ + baseDelayMs?: number; + + /** + * Whether to use exponential backoff for retry delays + * @default false + */ + useExponentialBackoff?: boolean; + + /** + * Custom function to determine if an error should trigger a retry + * @default Retries on timeout and abort errors + */ + shouldRetry?: (error: Error, attempt: number) => boolean; +} + +/** + * Default retry condition: retry on timeout and abort errors + */ +const defaultShouldRetry = (error: Error): boolean => { + const isTimeoutError = /timed out/i.test(error.message) || error.name === "AbortError"; + return isTimeoutError; +}; + +/** + * Calculate delay with optional exponential backoff + */ +const calculateDelay = ( + attempt: number, + baseDelayMs: number, + useExponentialBackoff: boolean +): number => { + if (useExponentialBackoff) { + return baseDelayMs * Math.pow(2, attempt - 1); + } + return baseDelayMs; +}; + +/** + * Sleep for specified milliseconds + */ +const sleep = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +/** + * A helper function that retries fetch requests on timeout or network errors. + * + * @param url - The URL to fetch + * @param requestOptions - Standard fetch RequestInit options + * @param retryOptions - Configuration for retry behavior + * @returns The parsed JSON response + * @throws Error if all retries are exhausted or a non-retryable error occurs + * + * @example + * ```typescript + * // Basic usage + * const data = await fetchWithRetries('/api/data'); + * + * // With custom options + * const data = await fetchWithRetries('/api/data', { + * method: 'POST', + * body: JSON.stringify({ key: 'value' }), + * }, { + * maxRetries: 3, + * useExponentialBackoff: true, + * }); + * ``` + */ +export async function fetchWithRetries( + url: string, + requestOptions: RequestInit = {}, + retryOptions: FetchWithRetriesOptions = {} +): Promise { + const { + maxRetries = 5, + baseDelayMs = 1000, + useExponentialBackoff = false, + shouldRetry = defaultShouldRetry, + } = retryOptions; + + let lastError: unknown = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(url, requestOptions); + + if (!res.ok) { + // For a non-200 response, parse the error body or throw generic error + const rawErrorData: unknown = await res.json().catch(() => ({})); + + if (typeof rawErrorData !== "object" || rawErrorData === null) { + throw new Error(`Request failed with status ${res.status}`); + } + + const errorData = rawErrorData as { error?: string }; + throw new Error(errorData.error ?? `Request failed with status ${res.status}`); + } + + // If fetch + response parsing is successful, return the JSON + const data: unknown = await res.json(); + return data; + } catch (err: unknown) { + lastError = err; + + // Check if error is retryable + if (err instanceof Error) { + const isRetryable = shouldRetry(err, attempt); + + if (isRetryable && attempt < maxRetries) { + const delay = calculateDelay(attempt, baseDelayMs, useExponentialBackoff); + console.warn( + `Attempt ${attempt}/${maxRetries} failed: ${err.message}. Retrying in ${delay}ms...` + ); + await sleep(delay); + continue; + } + + // If it's a non-retryable error or we've used all retries, re-throw + throw err; + } else { + // Wrap non-Error in a real Error + throw new Error(`Non-Error thrown: ${String(err)}`); + } + } + } + + // If we somehow exit the loop, throw the last error + if (!(lastError instanceof Error)) { + throw new Error(`Non-Error thrown: ${String(lastError)}`); + } + throw lastError; +} + +/** + * Convenience wrapper that maintains backward compatibility with the original + * fetchWithRetries signature (url, options, maxRetries). + * + * @deprecated Use fetchWithRetries with FetchWithRetriesOptions instead + */ +export async function fetchWithRetriesLegacy( + url: string, + options: RequestInit = {}, + maxRetries = 5 +): Promise { + return fetchWithRetries(url, options, { maxRetries }); +}