Skip to content

Commit c115f13

Browse files
feat: Phase 1 - Terminal reconnection foundation
- Update ConnectionStatus type: replace 'initializing' with 'connecting' - Create useRetry hook with exponential backoff logic - Add comprehensive tests for useRetry hook - Export useRetry from hooks index Implements: - Initial delay: 1 second - Max delay: 30 seconds - Backoff multiplier: 2 - Max retry attempts: 10 Co-authored-by: BrunoQuaresma <3165839+BrunoQuaresma@users.noreply.github.com>
1 parent 6f2834f commit c115f13

File tree

5 files changed

+521
-2
lines changed

5 files changed

+521
-2
lines changed

site/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./useClickable";
33
export * from "./useClickableTableRow";
44
export * from "./useClipboard";
55
export * from "./usePagination";
6+
export * from "./useRetry";

site/src/hooks/useRetry.test.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useRetry } from "./useRetry";
3+
4+
// Mock timers
5+
jest.useFakeTimers();
6+
7+
describe("useRetry", () => {
8+
const defaultOptions = {
9+
maxAttempts: 3,
10+
initialDelay: 1000,
11+
maxDelay: 8000,
12+
multiplier: 2,
13+
};
14+
15+
let mockOnRetry: jest.Mock;
16+
17+
beforeEach(() => {
18+
mockOnRetry = jest.fn();
19+
jest.clearAllTimers();
20+
});
21+
22+
afterEach(() => {
23+
jest.clearAllMocks();
24+
});
25+
26+
it("should initialize with correct default state", () => {
27+
const { result } = renderHook(() =>
28+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
29+
);
30+
31+
expect(result.current.isRetrying).toBe(false);
32+
expect(result.current.currentDelay).toBe(null);
33+
expect(result.current.attemptCount).toBe(0);
34+
expect(result.current.timeUntilNextRetry).toBe(null);
35+
});
36+
37+
it("should start retrying when startRetrying is called", async () => {
38+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
39+
40+
const { result } = renderHook(() =>
41+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
42+
);
43+
44+
act(() => {
45+
result.current.startRetrying();
46+
});
47+
48+
expect(result.current.attemptCount).toBe(1);
49+
expect(result.current.isRetrying).toBe(true);
50+
51+
// Wait for the retry to complete
52+
await act(async () => {
53+
await Promise.resolve();
54+
});
55+
56+
expect(mockOnRetry).toHaveBeenCalledTimes(1);
57+
expect(result.current.isRetrying).toBe(false);
58+
});
59+
60+
it("should calculate exponential backoff delays correctly", async () => {
61+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
62+
63+
const { result } = renderHook(() =>
64+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
65+
);
66+
67+
act(() => {
68+
result.current.startRetrying();
69+
});
70+
71+
// Wait for first retry to fail
72+
await act(async () => {
73+
await Promise.resolve();
74+
});
75+
76+
// Should schedule next retry with initial delay (1000ms)
77+
expect(result.current.currentDelay).toBe(1000);
78+
expect(result.current.timeUntilNextRetry).toBe(1000);
79+
80+
// Fast forward to trigger second retry
81+
act(() => {
82+
jest.advanceTimersByTime(1000);
83+
});
84+
85+
await act(async () => {
86+
await Promise.resolve();
87+
});
88+
89+
// Should schedule third retry with doubled delay (2000ms)
90+
expect(result.current.currentDelay).toBe(2000);
91+
});
92+
93+
it("should respect maximum delay", async () => {
94+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
95+
96+
const options = {
97+
...defaultOptions,
98+
maxDelay: 1500, // Lower max delay
99+
onRetry: mockOnRetry,
100+
};
101+
102+
const { result } = renderHook(() => useRetry(options));
103+
104+
act(() => {
105+
result.current.startRetrying();
106+
});
107+
108+
// Wait for first retry to fail
109+
await act(async () => {
110+
await Promise.resolve();
111+
});
112+
113+
// Fast forward to trigger second retry
114+
act(() => {
115+
jest.advanceTimersByTime(1000);
116+
});
117+
118+
await act(async () => {
119+
await Promise.resolve();
120+
});
121+
122+
// Should cap at maxDelay instead of 2000ms
123+
expect(result.current.currentDelay).toBe(1500);
124+
});
125+
126+
it("should stop retrying after max attempts", async () => {
127+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
128+
129+
const { result } = renderHook(() =>
130+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
131+
);
132+
133+
act(() => {
134+
result.current.startRetrying();
135+
});
136+
137+
// Simulate all retry attempts
138+
for (let i = 0; i < defaultOptions.maxAttempts; i++) {
139+
await act(async () => {
140+
await Promise.resolve();
141+
});
142+
143+
if (i < defaultOptions.maxAttempts - 1) {
144+
// Fast forward to next retry
145+
act(() => {
146+
jest.advanceTimersByTime(result.current.currentDelay || 0);
147+
});
148+
}
149+
}
150+
151+
expect(mockOnRetry).toHaveBeenCalledTimes(defaultOptions.maxAttempts);
152+
expect(result.current.attemptCount).toBe(defaultOptions.maxAttempts);
153+
expect(result.current.currentDelay).toBe(null);
154+
expect(result.current.timeUntilNextRetry).toBe(null);
155+
});
156+
157+
it("should handle manual retry", async () => {
158+
mockOnRetry.mockRejectedValueOnce(new Error("Connection failed"));
159+
mockOnRetry.mockResolvedValueOnce(undefined);
160+
161+
const { result } = renderHook(() =>
162+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
163+
);
164+
165+
act(() => {
166+
result.current.startRetrying();
167+
});
168+
169+
// Wait for first retry to fail
170+
await act(async () => {
171+
await Promise.resolve();
172+
});
173+
174+
expect(result.current.currentDelay).toBe(1000);
175+
176+
// Trigger manual retry before automatic retry
177+
act(() => {
178+
result.current.retry();
179+
});
180+
181+
// Should cancel automatic retry
182+
expect(result.current.currentDelay).toBe(null);
183+
expect(result.current.timeUntilNextRetry).toBe(null);
184+
expect(result.current.isRetrying).toBe(true);
185+
186+
await act(async () => {
187+
await Promise.resolve();
188+
});
189+
190+
// Should succeed and reset state
191+
expect(result.current.attemptCount).toBe(0);
192+
expect(result.current.isRetrying).toBe(false);
193+
});
194+
195+
it("should reset state when retry succeeds", async () => {
196+
mockOnRetry.mockRejectedValueOnce(new Error("Connection failed"));
197+
mockOnRetry.mockResolvedValueOnce(undefined);
198+
199+
const { result } = renderHook(() =>
200+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
201+
);
202+
203+
act(() => {
204+
result.current.startRetrying();
205+
});
206+
207+
// Wait for first retry to fail
208+
await act(async () => {
209+
await Promise.resolve();
210+
});
211+
212+
expect(result.current.attemptCount).toBe(1);
213+
214+
// Fast forward to trigger second retry (which will succeed)
215+
act(() => {
216+
jest.advanceTimersByTime(1000);
217+
});
218+
219+
await act(async () => {
220+
await Promise.resolve();
221+
});
222+
223+
// Should reset all state
224+
expect(result.current.attemptCount).toBe(0);
225+
expect(result.current.isRetrying).toBe(false);
226+
expect(result.current.currentDelay).toBe(null);
227+
expect(result.current.timeUntilNextRetry).toBe(null);
228+
});
229+
230+
it("should stop retrying when stopRetrying is called", async () => {
231+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
232+
233+
const { result } = renderHook(() =>
234+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
235+
);
236+
237+
act(() => {
238+
result.current.startRetrying();
239+
});
240+
241+
// Wait for first retry to fail
242+
await act(async () => {
243+
await Promise.resolve();
244+
});
245+
246+
expect(result.current.currentDelay).toBe(1000);
247+
248+
// Stop retrying
249+
act(() => {
250+
result.current.stopRetrying();
251+
});
252+
253+
// Should reset all state
254+
expect(result.current.attemptCount).toBe(0);
255+
expect(result.current.isRetrying).toBe(false);
256+
expect(result.current.currentDelay).toBe(null);
257+
expect(result.current.timeUntilNextRetry).toBe(null);
258+
259+
// Fast forward past when retry would have happened
260+
act(() => {
261+
jest.advanceTimersByTime(2000);
262+
});
263+
264+
// Should not have triggered additional retries
265+
expect(mockOnRetry).toHaveBeenCalledTimes(1);
266+
});
267+
268+
it("should update countdown timer correctly", async () => {
269+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
270+
271+
const { result } = renderHook(() =>
272+
useRetry({ ...defaultOptions, onRetry: mockOnRetry }),
273+
);
274+
275+
act(() => {
276+
result.current.startRetrying();
277+
});
278+
279+
// Wait for first retry to fail
280+
await act(async () => {
281+
await Promise.resolve();
282+
});
283+
284+
expect(result.current.timeUntilNextRetry).toBe(1000);
285+
286+
// Advance time partially
287+
act(() => {
288+
jest.advanceTimersByTime(300);
289+
});
290+
291+
// Should update countdown
292+
expect(result.current.timeUntilNextRetry).toBeLessThan(1000);
293+
expect(result.current.timeUntilNextRetry).toBeGreaterThan(0);
294+
});
295+
296+
it("should handle the specified backoff configuration", async () => {
297+
mockOnRetry.mockRejectedValue(new Error("Connection failed"));
298+
299+
// Test with the exact configuration from the issue
300+
const issueConfig = {
301+
onRetry: mockOnRetry,
302+
maxAttempts: 10,
303+
initialDelay: 1000, // 1 second
304+
maxDelay: 30000, // 30 seconds
305+
multiplier: 2,
306+
};
307+
308+
const { result } = renderHook(() => useRetry(issueConfig));
309+
310+
act(() => {
311+
result.current.startRetrying();
312+
});
313+
314+
// Test first few delays
315+
const expectedDelays = [1000, 2000, 4000, 8000, 16000, 30000]; // Caps at 30000
316+
317+
for (let i = 0; i < expectedDelays.length; i++) {
318+
await act(async () => {
319+
await Promise.resolve();
320+
});
321+
322+
if (i < expectedDelays.length - 1) {
323+
expect(result.current.currentDelay).toBe(expectedDelays[i]);
324+
act(() => {
325+
jest.advanceTimersByTime(expectedDelays[i]);
326+
});
327+
}
328+
}
329+
});
330+
});

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy