diff --git a/site/src/hooks/index.ts b/site/src/hooks/index.ts index 901fee8a50ded..4453e36fa4bb4 100644 --- a/site/src/hooks/index.ts +++ b/site/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from "./useClickable"; export * from "./useClickableTableRow"; export * from "./useClipboard"; export * from "./usePagination"; +export * from "./useWithRetry"; diff --git a/site/src/hooks/useWithRetry.test.ts b/site/src/hooks/useWithRetry.test.ts new file mode 100644 index 0000000000000..7ed7b4331f21e --- /dev/null +++ b/site/src/hooks/useWithRetry.test.ts @@ -0,0 +1,329 @@ +import { act, renderHook } from "@testing-library/react"; +import { useWithRetry } from "./useWithRetry"; + +// Mock timers +jest.useFakeTimers(); + +describe("useWithRetry", () => { + let mockFn: jest.Mock; + + beforeEach(() => { + mockFn = jest.fn(); + jest.clearAllTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize with correct default state", () => { + const { result } = renderHook(() => useWithRetry(mockFn)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).toBe(undefined); + }); + + it("should execute function successfully on first attempt", async () => { + mockFn.mockResolvedValue(undefined); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + await act(async () => { + await result.current.call(); + }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).toBe(undefined); + }); + + it("should set isLoading to true during execution", async () => { + let resolvePromise: () => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockFn.mockReturnValue(promise); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + act(() => { + result.current.call(); + }); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + resolvePromise!(); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it("should retry on failure with exponential backoff", async () => { + mockFn + .mockRejectedValueOnce(new Error("First failure")) + .mockRejectedValueOnce(new Error("Second failure")) + .mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + // Start the call + await act(async () => { + await result.current.call(); + }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + + // Fast-forward to first retry (1 second) + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + + // Fast-forward to second retry (2 seconds) + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + expect(mockFn).toHaveBeenCalledTimes(3); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).toBe(undefined); + }); + + it("should continue retrying without limit", async () => { + mockFn.mockRejectedValue(new Error("Always fails")); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + // Start the call + await act(async () => { + await result.current.call(); + }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + + // Fast-forward through multiple retries to verify it continues + for (let i = 1; i < 15; i++) { + const delay = Math.min(1000 * 2 ** (i - 1), 600000); // exponential backoff with max delay + await act(async () => { + jest.advanceTimersByTime(delay); + }); + expect(mockFn).toHaveBeenCalledTimes(i + 1); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + } + + // Should still be retrying after 15 attempts + expect(result.current.nextRetryAt).not.toBe(null); + }); + + it("should respect max delay of 10 minutes", async () => { + mockFn.mockRejectedValue(new Error("Always fails")); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + // Start the call + await act(async () => { + await result.current.call(); + }); + + expect(result.current.isLoading).toBe(false); + + // Fast-forward through several retries to reach max delay + // After attempt 9, delay would be 1000 * 2^9 = 512000ms, which is less than 600000ms (10 min) + // After attempt 10, delay would be 1000 * 2^10 = 1024000ms, which should be capped at 600000ms + + // Skip to attempt 9 (delay calculation: 1000 * 2^8 = 256000ms) + for (let i = 1; i < 9; i++) { + const delay = 1000 * 2 ** (i - 1); + await act(async () => { + jest.advanceTimersByTime(delay); + }); + } + + expect(mockFn).toHaveBeenCalledTimes(9); + expect(result.current.nextRetryAt).not.toBe(null); + + // The 9th retry should use max delay (600000ms = 10 minutes) + await act(async () => { + jest.advanceTimersByTime(600000); + }); + + expect(mockFn).toHaveBeenCalledTimes(10); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + + // Continue with more retries at max delay to verify it continues indefinitely + await act(async () => { + jest.advanceTimersByTime(600000); + }); + + expect(mockFn).toHaveBeenCalledTimes(11); + expect(result.current.nextRetryAt).not.toBe(null); + }); + + it("should cancel previous retry when call is invoked again", async () => { + mockFn + .mockRejectedValueOnce(new Error("First failure")) + .mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + // Start the first call + await act(async () => { + await result.current.call(); + }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + + // Call again before retry happens + await act(async () => { + await result.current.call(); + }); + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).toBe(undefined); + + // Advance time to ensure previous retry was cancelled + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(mockFn).toHaveBeenCalledTimes(2); // Should not have been called again + }); + + it("should set nextRetryAt when scheduling retry", async () => { + mockFn + .mockRejectedValueOnce(new Error("Failure")) + .mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + // Start the call + await act(async () => { + await result.current.call(); + }); + + const nextRetryAt = result.current.nextRetryAt; + expect(nextRetryAt).not.toBe(null); + expect(nextRetryAt).toBeInstanceOf(Date); + + // nextRetryAt should be approximately 1 second in the future + const expectedTime = Date.now() + 1000; + const actualTime = nextRetryAt!.getTime(); + expect(Math.abs(actualTime - expectedTime)).toBeLessThan(100); // Allow 100ms tolerance + + // Advance past retry time + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.nextRetryAt).toBe(undefined); + }); + + it("should cleanup timer on unmount", async () => { + mockFn.mockRejectedValue(new Error("Failure")); + + const { result, unmount } = renderHook(() => useWithRetry(mockFn)); + + // Start the call to create timer + await act(async () => { + await result.current.call(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.nextRetryAt).not.toBe(null); + + // Unmount should cleanup timer + unmount(); + + // Advance time to ensure timer was cleared + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + // Function should not have been called again + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it("should prevent scheduling retries when function completes after unmount", async () => { + let rejectPromise: (error: Error) => void; + const promise = new Promise((_, reject) => { + rejectPromise = reject; + }); + mockFn.mockReturnValue(promise); + + const { result, unmount } = renderHook(() => useWithRetry(mockFn)); + + // Start the call - this will make the function in-flight + act(() => { + result.current.call(); + }); + + expect(result.current.isLoading).toBe(true); + + // Unmount while function is still in-flight + unmount(); + + // Function completes with error after unmount + await act(async () => { + rejectPromise!(new Error("Failed after unmount")); + await promise.catch(() => {}); // Suppress unhandled rejection + }); + + // Advance time to ensure no retry timers were scheduled + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + // Function should only have been called once (no retries after unmount) + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it("should do nothing when call() is invoked while function is already loading", async () => { + let resolvePromise: () => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockFn.mockReturnValue(promise); + + const { result } = renderHook(() => useWithRetry(mockFn)); + + // Start the first call - this will set isLoading to true + act(() => { + result.current.call(); + }); + + expect(result.current.isLoading).toBe(true); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Try to call again while loading - should do nothing + act(() => { + result.current.call(); + }); + + // Function should not have been called again + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(true); + + // Complete the original promise + await act(async () => { + resolvePromise!(); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + expect(mockFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/site/src/hooks/useWithRetry.ts b/site/src/hooks/useWithRetry.ts new file mode 100644 index 0000000000000..1310da221efc5 --- /dev/null +++ b/site/src/hooks/useWithRetry.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffectEvent } from "./hookPolyfills"; + +const DELAY_MS = 1_000; +const MAX_DELAY_MS = 600_000; // 10 minutes +// Determines how much the delay between retry attempts increases after each +// failure. +const MULTIPLIER = 2; + +interface UseWithRetryResult { + call: () => void; + nextRetryAt: Date | undefined; + isLoading: boolean; +} + +interface RetryState { + isLoading: boolean; + nextRetryAt: Date | undefined; +} + +/** + * Hook that wraps a function with automatic retry functionality + * Provides a simple interface for executing functions with exponential backoff retry + */ +export function useWithRetry(fn: () => Promise): UseWithRetryResult { + const [state, setState] = useState({ + isLoading: false, + nextRetryAt: undefined, + }); + + const timeoutRef = useRef(null); + const mountedRef = useRef(true); + + const clearTimeout = useCallback(() => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const stableFn = useEffectEvent(fn); + + const call = useCallback(() => { + if (state.isLoading) { + return; + } + + clearTimeout(); + + const executeAttempt = async (attempt = 0): Promise => { + if (!mountedRef.current) { + return; + } + setState({ + isLoading: true, + nextRetryAt: undefined, + }); + + try { + await stableFn(); + if (mountedRef.current) { + setState({ isLoading: false, nextRetryAt: undefined }); + } + } catch (error) { + if (!mountedRef.current) { + return; + } + const delayMs = Math.min( + DELAY_MS * MULTIPLIER ** attempt, + MAX_DELAY_MS, + ); + + setState({ + isLoading: false, + nextRetryAt: new Date(Date.now() + delayMs), + }); + + timeoutRef.current = window.setTimeout(() => { + if (!mountedRef.current) { + return; + } + setState({ + isLoading: false, + nextRetryAt: undefined, + }); + executeAttempt(attempt + 1); + }, delayMs); + } + }; + + executeAttempt(); + }, [state.isLoading, stableFn, clearTimeout]); + + useEffect(() => { + return () => { + mountedRef.current = false; + clearTimeout(); + }; + }, [clearTimeout]); + + return { + call, + nextRetryAt: state.nextRetryAt, + isLoading: state.isLoading, + }; +}