Skip to content

Commit b1e453b

Browse files
Add useWithRetry hook for simplified retry functionality
- Created useWithRetry hook with simple interface (call, retryAt, isLoading) - Implements exponential backoff with configurable options - Includes comprehensive tests covering all scenarios - Added usage examples for different configurations - Follows existing code patterns and uses constants for configuration Co-authored-by: BrunoQuaresma <3165839+BrunoQuaresma@users.noreply.github.com>
1 parent 5766fc0 commit b1e453b

File tree

4 files changed

+499
-0
lines changed

4 files changed

+499
-0
lines changed

site/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from "./useClickableTableRow";
44
export * from "./useClipboard";
55
export * from "./usePagination";
66
export * from "./useRetry";
7+
export * from "./useWithRetry";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from "react";
2+
import { useWithRetry } from "./useWithRetry";
3+
4+
// Example component showing how to use useWithRetry
5+
export const TerminalConnectionExample: React.FC = () => {
6+
// Mock terminal connection function
7+
const connectToTerminal = async (): Promise<void> => {
8+
// Simulate connection that might fail
9+
if (Math.random() > 0.7) {
10+
throw new Error("Connection failed");
11+
}
12+
console.log("Connected to terminal successfully!");
13+
};
14+
15+
const { call: connectTerminal, isLoading, retryAt } = useWithRetry(
16+
connectToTerminal,
17+
{
18+
maxAttempts: 3,
19+
initialDelay: 1000,
20+
maxDelay: 5000,
21+
multiplier: 2,
22+
},
23+
);
24+
25+
const formatRetryTime = (date: Date): string => {
26+
const seconds = Math.ceil((date.getTime() - Date.now()) / 1000);
27+
return `${seconds}s`;
28+
};
29+
30+
return (
31+
<div>
32+
<button onClick={connectTerminal} disabled={isLoading}>
33+
{isLoading ? "Connecting..." : "Connect to Terminal"}
34+
</button>
35+
36+
{retryAt && (
37+
<div>
38+
<p>Connection failed. Retrying in {formatRetryTime(retryAt)}</p>
39+
</div>
40+
)}
41+
</div>
42+
);
43+
};
44+
45+
// Example with different configuration
46+
export const QuickRetryExample: React.FC = () => {
47+
const performAction = async (): Promise<void> => {
48+
// Simulate an action that might fail
49+
throw new Error("Action failed");
50+
};
51+
52+
const { call, isLoading, retryAt } = useWithRetry(performAction, {
53+
maxAttempts: 5,
54+
initialDelay: 500,
55+
multiplier: 1.5,
56+
});
57+
58+
return (
59+
<div>
60+
<button onClick={call} disabled={isLoading}>
61+
{isLoading ? "Processing..." : "Perform Action"}
62+
</button>
63+
64+
{retryAt && (
65+
<p>Retrying at {retryAt.toLocaleTimeString()}</p>
66+
)}
67+
</div>
68+
);
69+
};

site/src/hooks/useWithRetry.test.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useWithRetry } from "./useWithRetry";
3+
4+
// Mock timers
5+
jest.useFakeTimers();
6+
7+
describe("useWithRetry", () => {
8+
let mockFn: jest.Mock;
9+
10+
beforeEach(() => {
11+
mockFn = jest.fn();
12+
jest.clearAllTimers();
13+
});
14+
15+
afterEach(() => {
16+
jest.clearAllMocks();
17+
});
18+
19+
it("should initialize with correct default state", () => {
20+
const { result } = renderHook(() => useWithRetry(mockFn));
21+
22+
expect(result.current.isLoading).toBe(false);
23+
expect(result.current.retryAt).toBe(null);
24+
});
25+
26+
it("should execute function successfully on first attempt", async () => {
27+
mockFn.mockResolvedValue(undefined);
28+
29+
const { result } = renderHook(() => useWithRetry(mockFn));
30+
31+
await act(async () => {
32+
await result.current.call();
33+
});
34+
35+
expect(mockFn).toHaveBeenCalledTimes(1);
36+
expect(result.current.isLoading).toBe(false);
37+
expect(result.current.retryAt).toBe(null);
38+
});
39+
40+
it("should set isLoading to true during execution", async () => {
41+
let resolvePromise: () => void;
42+
const promise = new Promise<void>((resolve) => {
43+
resolvePromise = resolve;
44+
});
45+
mockFn.mockReturnValue(promise);
46+
47+
const { result } = renderHook(() => useWithRetry(mockFn));
48+
49+
act(() => {
50+
result.current.call();
51+
});
52+
53+
expect(result.current.isLoading).toBe(true);
54+
55+
await act(async () => {
56+
resolvePromise!();
57+
await promise;
58+
});
59+
60+
expect(result.current.isLoading).toBe(false);
61+
});
62+
63+
it("should retry on failure with exponential backoff", async () => {
64+
mockFn
65+
.mockRejectedValueOnce(new Error("First failure"))
66+
.mockRejectedValueOnce(new Error("Second failure"))
67+
.mockResolvedValueOnce(undefined);
68+
69+
const { result } = renderHook(() => useWithRetry(mockFn));
70+
71+
// Start the call
72+
await act(async () => {
73+
await result.current.call();
74+
});
75+
76+
expect(mockFn).toHaveBeenCalledTimes(1);
77+
expect(result.current.isLoading).toBe(true);
78+
expect(result.current.retryAt).not.toBe(null);
79+
80+
// Fast-forward to first retry (1 second)
81+
await act(async () => {
82+
jest.advanceTimersByTime(1000);
83+
});
84+
85+
expect(mockFn).toHaveBeenCalledTimes(2);
86+
expect(result.current.isLoading).toBe(true);
87+
expect(result.current.retryAt).not.toBe(null);
88+
89+
// Fast-forward to second retry (2 seconds)
90+
await act(async () => {
91+
jest.advanceTimersByTime(2000);
92+
});
93+
94+
expect(mockFn).toHaveBeenCalledTimes(3);
95+
expect(result.current.isLoading).toBe(false);
96+
expect(result.current.retryAt).toBe(null);
97+
});
98+
99+
it("should stop retrying after max attempts", async () => {
100+
mockFn.mockRejectedValue(new Error("Always fails"));
101+
102+
const { result } = renderHook(() =>
103+
useWithRetry(mockFn, { maxAttempts: 2 }),
104+
);
105+
106+
// Start the call
107+
await act(async () => {
108+
await result.current.call();
109+
});
110+
111+
expect(mockFn).toHaveBeenCalledTimes(1);
112+
expect(result.current.isLoading).toBe(true);
113+
114+
// Fast-forward to first retry
115+
await act(async () => {
116+
jest.advanceTimersByTime(1000);
117+
});
118+
119+
expect(mockFn).toHaveBeenCalledTimes(2);
120+
expect(result.current.isLoading).toBe(false);
121+
expect(result.current.retryAt).toBe(null);
122+
});
123+
124+
it("should use custom retry options", async () => {
125+
mockFn
126+
.mockRejectedValueOnce(new Error("First failure"))
127+
.mockResolvedValueOnce(undefined);
128+
129+
const { result } = renderHook(() =>
130+
useWithRetry(mockFn, {
131+
initialDelay: 500,
132+
multiplier: 3,
133+
maxAttempts: 2,
134+
}),
135+
);
136+
137+
// Start the call
138+
await act(async () => {
139+
await result.current.call();
140+
});
141+
142+
expect(mockFn).toHaveBeenCalledTimes(1);
143+
expect(result.current.isLoading).toBe(true);
144+
expect(result.current.retryAt).not.toBe(null);
145+
146+
// Fast-forward by custom initial delay (500ms)
147+
await act(async () => {
148+
jest.advanceTimersByTime(500);
149+
});
150+
151+
expect(mockFn).toHaveBeenCalledTimes(2);
152+
expect(result.current.isLoading).toBe(false);
153+
expect(result.current.retryAt).toBe(null);
154+
});
155+
156+
it("should respect max delay", async () => {
157+
mockFn.mockRejectedValue(new Error("Always fails"));
158+
159+
const { result } = renderHook(() =>
160+
useWithRetry(mockFn, {
161+
initialDelay: 1000,
162+
multiplier: 10,
163+
maxDelay: 2000,
164+
maxAttempts: 3,
165+
}),
166+
);
167+
168+
// Start the call
169+
await act(async () => {
170+
await result.current.call();
171+
});
172+
173+
expect(result.current.isLoading).toBe(true);
174+
175+
// First retry should be at 1000ms (initial delay)
176+
await act(async () => {
177+
jest.advanceTimersByTime(1000);
178+
});
179+
180+
expect(mockFn).toHaveBeenCalledTimes(2);
181+
182+
// Second retry should be at 2000ms (max delay, not 10000ms)
183+
await act(async () => {
184+
jest.advanceTimersByTime(2000);
185+
});
186+
187+
expect(mockFn).toHaveBeenCalledTimes(3);
188+
expect(result.current.isLoading).toBe(false);
189+
});
190+
191+
it("should cancel previous retry when call is invoked again", async () => {
192+
mockFn
193+
.mockRejectedValueOnce(new Error("First failure"))
194+
.mockResolvedValueOnce(undefined);
195+
196+
const { result } = renderHook(() => useWithRetry(mockFn));
197+
198+
// Start the first call
199+
await act(async () => {
200+
await result.current.call();
201+
});
202+
203+
expect(mockFn).toHaveBeenCalledTimes(1);
204+
expect(result.current.isLoading).toBe(true);
205+
expect(result.current.retryAt).not.toBe(null);
206+
207+
// Call again before retry happens
208+
await act(async () => {
209+
await result.current.call();
210+
});
211+
212+
expect(mockFn).toHaveBeenCalledTimes(2);
213+
expect(result.current.isLoading).toBe(false);
214+
expect(result.current.retryAt).toBe(null);
215+
216+
// Advance time to ensure previous retry was cancelled
217+
await act(async () => {
218+
jest.advanceTimersByTime(5000);
219+
});
220+
221+
expect(mockFn).toHaveBeenCalledTimes(2); // Should not have been called again
222+
});
223+
224+
it("should update retryAt countdown", async () => {
225+
mockFn.mockRejectedValue(new Error("Failure"));
226+
227+
const { result } = renderHook(() =>
228+
useWithRetry(mockFn, { initialDelay: 1000 }),
229+
);
230+
231+
// Start the call
232+
await act(async () => {
233+
await result.current.call();
234+
});
235+
236+
const initialRetryAt = result.current.retryAt;
237+
expect(initialRetryAt).not.toBe(null);
238+
239+
// Advance time by 100ms (countdown update interval)
240+
await act(async () => {
241+
jest.advanceTimersByTime(100);
242+
});
243+
244+
// retryAt should still be set but countdown should be updating
245+
expect(result.current.retryAt).not.toBe(null);
246+
247+
// Advance to just before retry time
248+
await act(async () => {
249+
jest.advanceTimersByTime(850);
250+
});
251+
252+
expect(result.current.retryAt).not.toBe(null);
253+
254+
// Advance past retry time
255+
await act(async () => {
256+
jest.advanceTimersByTime(100);
257+
});
258+
259+
expect(result.current.retryAt).toBe(null);
260+
});
261+
262+
it("should cleanup timers on unmount", async () => {
263+
mockFn.mockRejectedValue(new Error("Failure"));
264+
265+
const { result, unmount } = renderHook(() => useWithRetry(mockFn));
266+
267+
// Start the call to create timers
268+
await act(async () => {
269+
await result.current.call();
270+
});
271+
272+
expect(result.current.isLoading).toBe(true);
273+
expect(result.current.retryAt).not.toBe(null);
274+
275+
// Unmount should cleanup timers
276+
unmount();
277+
278+
// Advance time to ensure timers were cleared
279+
await act(async () => {
280+
jest.advanceTimersByTime(5000);
281+
});
282+
283+
// Function should not have been called again
284+
expect(mockFn).toHaveBeenCalledTimes(1);
285+
});
286+
});

0 commit comments

Comments
 (0)