Skip to content

Commit d1ea6f0

Browse files
authored
exponential back off (#355)
1 parent cd504c7 commit d1ea6f0

3 files changed

Lines changed: 575 additions & 2 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
/**
2+
* Unit tests for src/services/queue.ts
3+
*/
4+
import {
5+
fetchWithRetry,
6+
fetchWithQueue,
7+
queueOfflineRequest,
8+
drainOfflineQueue,
9+
getOfflineQueueState,
10+
clearOfflineQueue,
11+
} from '../services/queue';
12+
13+
// Mock the syncQueue module to avoid external dependencies
14+
jest.mock('../services/syncQueue', () => ({
15+
flushPendingNetworkActions: jest.fn().mockResolvedValue(undefined),
16+
}));
17+
18+
const mockFetch = jest.fn();
19+
global.fetch = mockFetch as typeof fetch;
20+
21+
beforeEach(() => {
22+
mockFetch.mockReset();
23+
clearOfflineQueue();
24+
});
25+
26+
describe('fetchWithRetry', () => {
27+
it('returns response on first success', async () => {
28+
const payload = { status: 'ok' };
29+
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => payload });
30+
const result = await fetchWithRetry('https://api.example.com/health');
31+
expect(result.ok).toBe(true);
32+
expect(mockFetch).toHaveBeenCalledTimes(1);
33+
});
34+
35+
it('retries on 500 error and succeeds eventually', async () => {
36+
const payload = { status: 'ok' };
37+
mockFetch
38+
.mockResolvedValueOnce({ ok: false, status: 500 })
39+
.mockResolvedValueOnce({ ok: false, status: 500 })
40+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
41+
42+
const result = await fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 });
43+
expect(result.ok).toBe(true);
44+
expect(mockFetch).toHaveBeenCalledTimes(3);
45+
});
46+
47+
it('retries on 503 error and succeeds eventually', async () => {
48+
const payload = { status: 'ok' };
49+
mockFetch
50+
.mockResolvedValueOnce({ ok: false, status: 503 })
51+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
52+
53+
const result = await fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 });
54+
expect(result.ok).toBe(true);
55+
expect(mockFetch).toHaveBeenCalledTimes(2);
56+
});
57+
58+
it('retries on 429 rate limit error and succeeds eventually', async () => {
59+
const payload = { status: 'ok' };
60+
mockFetch
61+
.mockResolvedValueOnce({ ok: false, status: 429 })
62+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
63+
64+
const result = await fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 });
65+
expect(result.ok).toBe(true);
66+
expect(mockFetch).toHaveBeenCalledTimes(2);
67+
});
68+
69+
it('throws after max retries on persistent 500 errors', async () => {
70+
mockFetch
71+
.mockResolvedValueOnce({ ok: false, status: 500 })
72+
.mockResolvedValueOnce({ ok: false, status: 500 })
73+
.mockResolvedValueOnce({ ok: false, status: 500 })
74+
.mockResolvedValueOnce({ ok: false, status: 500 });
75+
76+
await expect(
77+
fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 }),
78+
).rejects.toThrow('HTTP error! status: 500');
79+
expect(mockFetch).toHaveBeenCalledTimes(4); // Initial + 3 retries
80+
});
81+
82+
it('retries on network error and succeeds eventually', async () => {
83+
const payload = { status: 'ok' };
84+
mockFetch
85+
.mockRejectedValueOnce(new Error('Network request failed'))
86+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
87+
88+
const result = await fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 });
89+
expect(result.ok).toBe(true);
90+
expect(mockFetch).toHaveBeenCalledTimes(2);
91+
});
92+
93+
it('retries on timeout error and succeeds eventually', async () => {
94+
const payload = { status: 'ok' };
95+
mockFetch
96+
.mockRejectedValueOnce(new Error('timeout'))
97+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
98+
99+
const result = await fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 });
100+
expect(result.ok).toBe(true);
101+
expect(mockFetch).toHaveBeenCalledTimes(2);
102+
});
103+
104+
it('does not retry on 4xx client errors', async () => {
105+
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
106+
107+
await expect(fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 })).rejects.toThrow('HTTP error! status: 404');
108+
expect(mockFetch).toHaveBeenCalledTimes(1);
109+
});
110+
111+
it('does not retry on 401 unauthorized', async () => {
112+
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
113+
114+
await expect(fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 })).rejects.toThrow('HTTP error! status: 401');
115+
expect(mockFetch).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('respects custom maxRetries option', async () => {
119+
const payload = { status: 'ok' };
120+
mockFetch
121+
.mockResolvedValueOnce({ ok: false, status: 500 })
122+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
123+
124+
const result = await fetchWithRetry('https://api.example.com/health', undefined, { maxRetries: 1, baseDelayMs: 10 });
125+
expect(result.ok).toBe(true);
126+
expect(mockFetch).toHaveBeenCalledTimes(2);
127+
});
128+
129+
it('uses exponential backoff with jitter', async () => {
130+
const payload = { status: 'ok' };
131+
mockFetch
132+
.mockResolvedValueOnce({ ok: false, status: 500 })
133+
.mockResolvedValueOnce({ ok: false, status: 500 })
134+
.mockResolvedValueOnce({ ok: true, json: async () => payload });
135+
136+
const startTime = Date.now();
137+
await fetchWithRetry('https://api.example.com/health', undefined, { baseDelayMs: 10 });
138+
const elapsed = Date.now() - startTime;
139+
140+
// Should have waited at least 30ms total (10 + 20)
141+
expect(elapsed).toBeGreaterThanOrEqual(30);
142+
expect(mockFetch).toHaveBeenCalledTimes(3);
143+
});
144+
});
145+
146+
describe('queueOfflineRequest', () => {
147+
it('adds request to offline queue', () => {
148+
const request = queueOfflineRequest('https://api.example.com/data', { method: 'GET' });
149+
150+
expect(request.url).toBe('https://api.example.com/data');
151+
expect(request.options).toEqual({ method: 'GET' });
152+
expect(request.retryCount).toBe(0);
153+
expect(request.id).toBeDefined();
154+
});
155+
156+
it('generates unique IDs for each request', () => {
157+
const request1 = queueOfflineRequest('https://api.example.com/data');
158+
const request2 = queueOfflineRequest('https://api.example.com/data');
159+
160+
expect(request1.id).not.toBe(request2.id);
161+
});
162+
163+
it('stores requests in queue', () => {
164+
queueOfflineRequest('https://api.example.com/data1');
165+
queueOfflineRequest('https://api.example.com/data2');
166+
167+
const queue = getOfflineQueueState();
168+
expect(queue).toHaveLength(2);
169+
});
170+
});
171+
172+
describe('fetchWithQueue', () => {
173+
it('returns response when online', async () => {
174+
const payload = { status: 'ok' };
175+
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => payload });
176+
177+
const result = await fetchWithQueue('https://api.example.com/health', undefined, { isOnline: true });
178+
expect(result.ok).toBe(true);
179+
expect(mockFetch).toHaveBeenCalledTimes(1);
180+
});
181+
182+
it('queues request when offline', async () => {
183+
await expect(
184+
fetchWithQueue('https://api.example.com/health', undefined, { isOnline: false }),
185+
).rejects.toThrow('Offline: request queued');
186+
187+
const queue = getOfflineQueueState();
188+
expect(queue).toHaveLength(1);
189+
expect(queue[0].url).toBe('https://api.example.com/health');
190+
});
191+
192+
it('queues request on network error', async () => {
193+
mockFetch.mockRejectedValue(new Error('Network request failed'));
194+
195+
await expect(
196+
fetchWithQueue('https://api.example.com/health', undefined, { isOnline: true, baseDelayMs: 10 }),
197+
).rejects.toThrow();
198+
199+
const queue = getOfflineQueueState();
200+
expect(queue).toHaveLength(1);
201+
});
202+
203+
it('does not queue request on non-retryable error', async () => {
204+
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
205+
206+
await expect(
207+
fetchWithQueue('https://api.example.com/health', undefined, { isOnline: true, baseDelayMs: 10 }),
208+
).rejects.toThrow();
209+
210+
const queue = getOfflineQueueState();
211+
expect(queue).toHaveLength(0);
212+
});
213+
214+
it('retries with exponential backoff before throwing error', async () => {
215+
const payload = { status: 'ok' };
216+
mockFetch
217+
.mockResolvedValueOnce({ ok: false, status: 500 })
218+
.mockResolvedValueOnce({ ok: false, status: 500 })
219+
.mockResolvedValueOnce({ ok: false, status: 500 })
220+
.mockResolvedValueOnce({ ok: false, status: 500 });
221+
222+
await expect(
223+
fetchWithQueue('https://api.example.com/health', undefined, { isOnline: true, baseDelayMs: 10 }),
224+
).rejects.toThrow();
225+
226+
// Server errors after exhausting retries should NOT be queued
227+
// Only network/offline errors should be queued
228+
const queue = getOfflineQueueState();
229+
expect(queue).toHaveLength(0);
230+
expect(mockFetch).toHaveBeenCalledTimes(4);
231+
});
232+
});
233+
234+
describe('drainOfflineQueue', () => {
235+
it('processes queued requests when online', async () => {
236+
const payload = { status: 'ok' };
237+
mockFetch.mockResolvedValue({ ok: true, json: async () => payload });
238+
239+
queueOfflineRequest('https://api.example.com/data1');
240+
queueOfflineRequest('https://api.example.com/data2');
241+
242+
await drainOfflineQueue();
243+
244+
expect(mockFetch).toHaveBeenCalledTimes(2);
245+
expect(getOfflineQueueState()).toHaveLength(0);
246+
});
247+
248+
it('clears queue after successful processing', async () => {
249+
const payload = { status: 'ok' };
250+
mockFetch.mockResolvedValue({ ok: true, json: async () => payload });
251+
252+
queueOfflineRequest('https://api.example.com/data');
253+
expect(getOfflineQueueState()).toHaveLength(1);
254+
255+
await drainOfflineQueue();
256+
257+
expect(getOfflineQueueState()).toHaveLength(0);
258+
});
259+
260+
it('re-queues failed requests with incremented retry count', async () => {
261+
mockFetch.mockRejectedValue(new Error('Network error'));
262+
263+
queueOfflineRequest('https://api.example.com/data');
264+
265+
await drainOfflineQueue({ baseDelayMs: 10 });
266+
267+
const queue = getOfflineQueueState();
268+
expect(queue).toHaveLength(1);
269+
expect(queue[0].retryCount).toBe(1);
270+
});
271+
272+
it('removes requests after max retries', async () => {
273+
mockFetch.mockRejectedValue(new Error('Network error'));
274+
275+
const request = queueOfflineRequest('https://api.example.com/data');
276+
request.retryCount = 3; // Already at max retries
277+
278+
await drainOfflineQueue({ baseDelayMs: 10 });
279+
280+
const queue = getOfflineQueueState();
281+
expect(queue).toHaveLength(0);
282+
});
283+
284+
it('does nothing when queue is empty', async () => {
285+
await drainOfflineQueue();
286+
287+
expect(mockFetch).not.toHaveBeenCalled();
288+
});
289+
290+
it('handles mix of successful and failed requests', async () => {
291+
const payload = { status: 'ok' };
292+
mockFetch
293+
.mockResolvedValueOnce({ ok: true, json: async () => payload }) // data1 success
294+
.mockRejectedValueOnce(new Error('Network error')) // data2 fail
295+
.mockRejectedValueOnce(new Error('Network error')) // data2 retry 1
296+
.mockRejectedValueOnce(new Error('Network error')) // data2 retry 2
297+
.mockRejectedValueOnce(new Error('Network error')) // data2 retry 3
298+
.mockResolvedValueOnce({ ok: true, json: async () => payload }); // data3 success
299+
300+
queueOfflineRequest('https://api.example.com/data1');
301+
queueOfflineRequest('https://api.example.com/data2');
302+
queueOfflineRequest('https://api.example.com/data3');
303+
304+
await drainOfflineQueue({ baseDelayMs: 10 });
305+
306+
// First request succeeds immediately (1 call)
307+
// Second request fails, gets retried 3 times (4 calls total for this request)
308+
// Third request succeeds (1 call)
309+
// Total: 6 calls
310+
expect(mockFetch).toHaveBeenCalledTimes(6);
311+
const queue = getOfflineQueueState();
312+
expect(queue).toHaveLength(1);
313+
expect(queue[0].url).toBe('https://api.example.com/data2');
314+
expect(queue[0].retryCount).toBe(1);
315+
});
316+
});
317+
318+
describe('getOfflineQueueState', () => {
319+
it('returns copy of queue state', () => {
320+
queueOfflineRequest('https://api.example.com/data');
321+
322+
const state1 = getOfflineQueueState();
323+
const state2 = getOfflineQueueState();
324+
325+
expect(state1).toEqual(state2);
326+
expect(state1).not.toBe(state2);
327+
});
328+
329+
it('returns empty array when queue is empty', () => {
330+
const state = getOfflineQueueState();
331+
expect(state).toEqual([]);
332+
});
333+
});
334+
335+
describe('clearOfflineQueue', () => {
336+
it('clears all queued requests', () => {
337+
queueOfflineRequest('https://api.example.com/data1');
338+
queueOfflineRequest('https://api.example.com/data2');
339+
340+
expect(getOfflineQueueState()).toHaveLength(2);
341+
342+
clearOfflineQueue();
343+
344+
expect(getOfflineQueueState()).toHaveLength(0);
345+
});
346+
});

app/mobile/src/services/api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from '../config';
2+
import { fetchWithQueue } from './queue';
23

34
const API_URL = config.apiUrl;
45

@@ -13,7 +14,7 @@ export interface HealthStatus {
1314

1415
export const fetchHealthStatus = async (): Promise<HealthStatus> => {
1516
try {
16-
const response = await fetch(`${API_URL}/health`);
17+
const response = await fetchWithQueue(`${API_URL}/health`);
1718

1819
if (!response.ok) {
1920
throw new Error(`HTTP error! status: ${response.status}`);
@@ -37,7 +38,7 @@ export interface AidPackage {
3738

3839
export const getAidPackages = async (): Promise<AidPackage[]> => {
3940
try {
40-
const response = await fetch(`${API_URL}/aid`);
41+
const response = await fetchWithQueue(`${API_URL}/aid`);
4142
if (!response.ok) {
4243
throw new Error(`HTTP error! status: ${response.status}`);
4344
}

0 commit comments

Comments
 (0)