Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 456 lines 10 kB view raw
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});