Skip to content

Commit 8e098ca

Browse files
arbrandesclaude
andcommitted
fix: use smart retry that skips 4xx errors but retries server/network errors
Client errors (4xx) won't resolve on retry, so skip them. Server errors (5xx) and network failures get up to 3 retries with React Query's default exponential backoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 92ff155 commit 8e098ca

2 files changed

Lines changed: 52 additions & 3 deletions

File tree

src/data/hooks/queryHooks.test.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const createWrapper = (queryClient?: QueryClient) => {
4040
defaultOptions: {
4141
queries: {
4242
retry: false,
43+
retryDelay: 0,
4344
gcTime: 0,
4445
},
4546
mutations: {
@@ -96,12 +97,14 @@ describe('queryHooks', () => {
9697
throw new Error('Function not implemented.');
9798
},
9899
});
99-
(api.initializeList as jest.Mock).mockRejectedValue(new Error('API Error'));
100+
const error: any = new Error('API Error');
101+
error.response = { status: 403 };
102+
(api.initializeList as jest.Mock).mockRejectedValue(error);
100103

101104
// Don't use gcTime: 0 here — we need the seeded cache entry to persist
102105
// for the fallback lookup via queryClient.getQueryData()
103106
const queryClient = new QueryClient({
104-
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
107+
defaultOptions: { queries: { retry: false, retryDelay: 0 }, mutations: { retry: false } },
105108
});
106109
queryClient.setQueryData(
107110
learnerDashboardQueryKeys.initialize(undefined),
@@ -120,6 +123,48 @@ describe('queryHooks', () => {
120123
expect(result.current.data).toEqual(mockNormalUserData);
121124
});
122125

126+
it('should not retry on 4xx errors', async () => {
127+
mockUseMasquerade.mockReturnValue({
128+
masqueradeUser: null,
129+
setMasqueradeUser(): void { throw new Error('Function not implemented.'); },
130+
});
131+
const error: any = new Error('Forbidden');
132+
error.response = { status: 403 };
133+
(api.initializeList as jest.Mock).mockRejectedValue(error);
134+
135+
const { result } = renderHook(() => useInitializeLearnerHome(), {
136+
wrapper: createWrapper(),
137+
});
138+
139+
await waitFor(() => {
140+
expect(result.current.isError).toBe(true);
141+
});
142+
143+
// 4xx errors should not be retried — only 1 call
144+
expect(api.initializeList).toHaveBeenCalledTimes(1);
145+
});
146+
147+
it('should retry on 5xx errors up to 3 times', async () => {
148+
mockUseMasquerade.mockReturnValue({
149+
masqueradeUser: null,
150+
setMasqueradeUser(): void { throw new Error('Function not implemented.'); },
151+
});
152+
const error: any = new Error('Server Error');
153+
error.response = { status: 500 };
154+
(api.initializeList as jest.Mock).mockRejectedValue(error);
155+
156+
const { result } = renderHook(() => useInitializeLearnerHome(), {
157+
wrapper: createWrapper(),
158+
});
159+
160+
await waitFor(() => {
161+
expect(result.current.isError).toBe(true);
162+
});
163+
164+
// 1 initial + 3 retries = 4 total calls
165+
expect(api.initializeList).toHaveBeenCalledTimes(4);
166+
});
167+
123168
it('should have correct query configuration for masquerading', async () => {
124169
const masqueradeUser = 'test-user';
125170
mockUseMasquerade.mockReturnValue({

src/data/hooks/queryHooks.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ const useInitializeLearnerHome = () => {
2323
};
2424
},
2525
staleTime: 5 * 60 * 1000, // 5 minutes — dashboard data rarely changes while viewing
26-
retry: false,
26+
retry: (failureCount, error: any) => {
27+
// Don't retry client errors (4xx) — they won't resolve on retry
28+
if (error?.response?.status >= 400 && error?.response?.status < 500) return false;
29+
return failureCount < 3;
30+
},
2731
retryOnMount: !masqueradeUser,
2832
refetchOnMount: !masqueradeUser,
2933
});

0 commit comments

Comments
 (0)