Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1import {
2 Source,
3 pipe,
4 map,
5 makeSubject,
6 mergeMap,
7 fromValue,
8 fromArray,
9 publish,
10 tap,
11} from 'wonka';
12import { vi, expect, it, beforeEach, afterEach } from 'vitest';
13
14import {
15 gql,
16 createClient,
17 makeOperation,
18 Operation,
19 OperationResult,
20 ExchangeIO,
21} from '@urql/core';
22
23import { retryExchange } from './retryExchange';
24
25const dispatchDebug = vi.fn();
26
27beforeEach(() => {
28 vi.useFakeTimers();
29});
30
31afterEach(() => {
32 vi.useRealTimers();
33});
34
35const mockOptions = {
36 initialDelayMs: 50,
37 maxDelayMs: 500,
38 randomDelay: true,
39 maxNumberAttempts: 10,
40 retryIf: () => true,
41};
42
43const queryOne = gql`
44 {
45 author {
46 id
47 name
48 }
49 }
50`;
51
52const queryOneData = {
53 __typename: 'Query',
54 author: {
55 __typename: 'Author',
56 id: '123',
57 name: 'Author',
58 },
59};
60
61const queryOneError = {
62 name: 'error',
63 message: 'scary error',
64};
65
66let client, op, ops$, next;
67beforeEach(() => {
68 client = createClient({
69 url: 'http://0.0.0.0',
70 exchanges: [],
71 });
72 op = client.createRequestOperation('query', {
73 key: 1,
74 query: queryOne,
75 });
76
77 ({ source: ops$, next } = makeSubject<Operation>());
78});
79
80it('retries if it hits an error and works for multiple concurrent operations', () => {
81 const queryTwo = gql`
82 {
83 films {
84 id
85 name
86 }
87 }
88 `;
89 const queryTwoError = {
90 name: 'error2',
91 message: 'scary error2',
92 };
93 const opTwo = client.createRequestOperation('query', {
94 key: 2,
95 query: queryTwo,
96 });
97
98 const response = vi.fn((forwardOp: Operation): OperationResult => {
99 expect(
100 forwardOp.key === op.key || forwardOp.key === opTwo.key
101 ).toBeTruthy();
102
103 return {
104 operation: forwardOp,
105 // @ts-ignore
106 error: forwardOp.key === 2 ? queryTwoError : queryOneError,
107 };
108 });
109
110 const result = vi.fn();
111 const forward: ExchangeIO = ops$ => {
112 return pipe(ops$, map(response));
113 };
114
115 const mockRetryIf = vi.fn(() => true);
116
117 pipe(
118 retryExchange({
119 ...mockOptions,
120 retryIf: mockRetryIf,
121 })({
122 forward,
123 client,
124 dispatchDebug,
125 })(ops$),
126 tap(result),
127 publish
128 );
129
130 next(op);
131
132 expect(mockRetryIf).toHaveBeenCalledTimes(1);
133 expect(mockRetryIf).toHaveBeenCalledWith(queryOneError as any, op);
134
135 vi.runAllTimers();
136
137 expect(mockRetryIf).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
138
139 expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
140
141 // result should only ever be called once per operation
142 expect(result).toHaveBeenCalledTimes(1);
143
144 next(opTwo);
145
146 vi.runAllTimers();
147
148 expect(mockRetryIf).toHaveBeenCalledWith(queryTwoError as any, opTwo);
149
150 // max number of retries for each op
151 expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts * 2);
152 expect(result).toHaveBeenCalledTimes(2);
153});
154
155it('should retry x number of times and then return the successful result', () => {
156 const numberRetriesBeforeSuccess = 3;
157 const response = vi.fn((forwardOp: Operation): OperationResult => {
158 expect(forwardOp.key).toBe(op.key);
159 // @ts-ignore
160 return {
161 operation: forwardOp,
162 ...(forwardOp.context.retry?.count >= numberRetriesBeforeSuccess
163 ? { data: queryOneData }
164 : { error: queryOneError }),
165 };
166 });
167
168 const result = vi.fn();
169 const forward: ExchangeIO = ops$ => {
170 return pipe(ops$, map(response));
171 };
172
173 const mockRetryIf = vi.fn(() => true);
174
175 pipe(
176 retryExchange({
177 ...mockOptions,
178 retryIf: mockRetryIf,
179 })({
180 forward,
181 client,
182 dispatchDebug,
183 })(ops$),
184 tap(result),
185 publish
186 );
187
188 next(op);
189 vi.runAllTimers();
190
191 expect(mockRetryIf).toHaveBeenCalledTimes(numberRetriesBeforeSuccess);
192 expect(mockRetryIf).toHaveBeenCalledWith(queryOneError as any, op);
193
194 // one for original source, one for retry
195 expect(response).toHaveBeenCalledTimes(1 + numberRetriesBeforeSuccess);
196 expect(result).toHaveBeenCalledTimes(1);
197});
198
199it('should reset the retry counter if an operation succeeded first', () => {
200 let call = 0;
201 const response = vi.fn((forwardOp: Operation): Source<any> => {
202 expect(forwardOp.key).toBe(op.key);
203 if (call === 0) {
204 call++;
205 return fromValue({
206 operation: forwardOp,
207 error: queryOneError,
208 } as any);
209 } else if (call === 1) {
210 call++;
211 return fromArray([
212 {
213 operation: forwardOp,
214 error: queryOneError,
215 } as any,
216 {
217 operation: forwardOp,
218 data: queryOneData,
219 } as any,
220 ]);
221 } else {
222 expect(forwardOp.context.retry).toEqual({ count: 0, delay: null });
223
224 return fromValue({
225 operation: forwardOp,
226 data: queryOneData,
227 } as any);
228 }
229 });
230
231 const result = vi.fn();
232 const forward: ExchangeIO = ops$ => {
233 return pipe(ops$, mergeMap(response));
234 };
235
236 const mockRetryIf = vi.fn(() => true);
237
238 pipe(
239 retryExchange({
240 ...mockOptions,
241 retryIf: mockRetryIf,
242 })({
243 forward,
244 client,
245 dispatchDebug,
246 })(ops$),
247 tap(result),
248 publish
249 );
250
251 next(op);
252 vi.runAllTimers();
253
254 expect(mockRetryIf).toHaveBeenCalledTimes(2);
255 expect(mockRetryIf).toHaveBeenCalledWith(queryOneError as any, op);
256
257 expect(response).toHaveBeenCalledTimes(3);
258 expect(result).toHaveBeenCalledTimes(2);
259});
260
261it(`should still retry if retryIf undefined but there is a networkError`, () => {
262 const errorWithNetworkError = {
263 ...queryOneError,
264 networkError: 'scary network error',
265 };
266 const response = vi.fn((forwardOp: Operation): OperationResult => {
267 expect(forwardOp.key).toBe(op.key);
268 return {
269 operation: forwardOp,
270 // @ts-ignore
271 error: errorWithNetworkError,
272 };
273 });
274
275 const result = vi.fn();
276 const forward: ExchangeIO = ops$ => {
277 return pipe(ops$, map(response));
278 };
279
280 pipe(
281 retryExchange({
282 ...mockOptions,
283 retryIf: undefined,
284 })({
285 forward,
286 client,
287 dispatchDebug,
288 })(ops$),
289 tap(result),
290 publish
291 );
292
293 next(op);
294
295 vi.runAllTimers();
296
297 // max number of retries, plus original call
298 expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
299 expect(result).toHaveBeenCalledTimes(1);
300});
301
302it('should allow retryWhen to return falsy value and act as replacement of retryIf', () => {
303 const errorWithNetworkError = {
304 ...queryOneError,
305 networkError: 'scary network error',
306 };
307 const response = vi.fn((forwardOp: Operation): OperationResult => {
308 expect(forwardOp.key).toBe(op.key);
309 return {
310 operation: forwardOp,
311 // @ts-ignore
312 error: errorWithNetworkError,
313 };
314 });
315
316 const result = vi.fn();
317 const forward: ExchangeIO = ops$ => {
318 return pipe(ops$, map(response));
319 };
320
321 const retryWith = vi.fn(() => null);
322
323 pipe(
324 retryExchange({
325 ...mockOptions,
326 retryIf: undefined,
327 retryWith,
328 })({
329 forward,
330 client,
331 dispatchDebug,
332 })(ops$),
333 tap(result),
334 publish
335 );
336
337 next(op);
338
339 vi.runAllTimers();
340
341 // max number of retries, plus original call
342 expect(retryWith).toHaveBeenCalledTimes(1);
343 expect(response).toHaveBeenCalledTimes(1);
344 expect(result).toHaveBeenCalledTimes(1);
345});
346
347it('should allow retryWhen to return new operations when retrying', () => {
348 const errorWithNetworkError = {
349 ...queryOneError,
350 networkError: 'scary network error',
351 };
352 const response = vi.fn((forwardOp: Operation): OperationResult => {
353 expect(forwardOp.key).toBe(op.key);
354 return {
355 operation: forwardOp,
356 // @ts-ignore
357 error: errorWithNetworkError,
358 };
359 });
360
361 const result = vi.fn();
362 const forward: ExchangeIO = ops$ => {
363 return pipe(ops$, map(response));
364 };
365
366 const retryWith = vi.fn((_error, operation) => {
367 return makeOperation(operation.kind, operation, {
368 ...operation.context,
369 counter: (operation.context?.counter || 0) + 1,
370 });
371 });
372
373 pipe(
374 retryExchange({
375 ...mockOptions,
376 retryIf: undefined,
377 retryWith,
378 })({
379 forward,
380 client,
381 dispatchDebug,
382 })(ops$),
383 tap(result),
384 publish
385 );
386
387 next(op);
388
389 vi.runAllTimers();
390
391 // max number of retries, plus original call
392 expect(retryWith).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts - 1);
393 expect(response).toHaveBeenCalledTimes(mockOptions.maxNumberAttempts);
394 expect(result).toHaveBeenCalledTimes(1);
395
396 expect(response.mock.calls[1][0]).toHaveProperty('context.counter', 1);
397 expect(response.mock.calls[2][0]).toHaveProperty('context.counter', 2);
398});
399
400it('should increase retries by initialDelayMs for each subsequent failure', () => {
401 const errorWithNetworkError = {
402 ...queryOneError,
403 networkError: 'scary network error',
404 };
405 const response = vi.fn((forwardOp: Operation): OperationResult => {
406 expect(forwardOp.key).toBe(op.key);
407 return {
408 operation: forwardOp,
409 // @ts-ignore
410 error: errorWithNetworkError,
411 };
412 });
413
414 const result = vi.fn();
415 const forward: ExchangeIO = ops$ => {
416 return pipe(ops$, map(response));
417 };
418
419 const retryWith = vi.fn((_error, operation) => {
420 return makeOperation(operation.kind, operation, {
421 ...operation.context,
422 counter: (operation.context?.counter || 0) + 1,
423 });
424 });
425
426 const fixedDelayMs = 50;
427
428 const fixedDelayOptions = {
429 ...mockOptions,
430 randomDelay: false,
431 initialDelayMs: fixedDelayMs,
432 };
433
434 pipe(
435 retryExchange({
436 ...fixedDelayOptions,
437 retryIf: undefined,
438 retryWith,
439 })({
440 forward,
441 client,
442 dispatchDebug,
443 })(ops$),
444 tap(result),
445 publish
446 );
447
448 next(op);
449
450 // delay between each call should be increased by initialDelayMs
451 // (e.g. if initialDelayMs is 5s, first retry is waits 5 seconds, second retry waits 10 seconds)
452 for (let i = 1; i <= fixedDelayOptions.maxNumberAttempts; i++) {
453 expect(response).toHaveBeenCalledTimes(i);
454 vi.advanceTimersByTime(i * fixedDelayOptions.initialDelayMs);
455 }
456});