|
1 | | -import {retryAwareRequest} from './api.js' |
| 1 | +import {retryAwareRequest, isNetworkError, isTransientNetworkError} from './api.js' |
2 | 2 | import {ClientError} from 'graphql-request' |
3 | 3 | import {describe, test, vi, expect, beforeEach, afterEach} from 'vitest' |
4 | 4 |
|
@@ -342,4 +342,153 @@ describe('retryAwareRequest', () => { |
342 | 342 | await expect(result).rejects.toThrowError(nonBlankUnknownReason) |
343 | 343 | expect(mockRequestFn).toHaveBeenCalledTimes(1) |
344 | 344 | }) |
| 345 | + |
| 346 | + test('does not retry certificate/TLS/SSL errors (permanent network errors)', async () => { |
| 347 | + vi.useRealTimers() |
| 348 | + const certificateErrors = [ |
| 349 | + 'certificate has expired', |
| 350 | + "Hostname/IP does not match certificate's altnames", |
| 351 | + 'TLS handshake failed', |
| 352 | + 'SSL certificate problem: unable to get local issuer certificate', |
| 353 | + ] |
| 354 | + |
| 355 | + await Promise.all( |
| 356 | + certificateErrors.map(async (certError) => { |
| 357 | + const mockRequestFn = vi.fn().mockImplementation(() => { |
| 358 | + throw new Error(certError) |
| 359 | + }) |
| 360 | + |
| 361 | + const result = retryAwareRequest( |
| 362 | + { |
| 363 | + request: mockRequestFn, |
| 364 | + url: 'https://example.com/graphql.json', |
| 365 | + useNetworkLevelRetry: true, |
| 366 | + maxRetryTimeMs: 2000, |
| 367 | + }, |
| 368 | + undefined, |
| 369 | + {defaultDelayMs: 10, scheduleDelay: (fn) => fn()}, |
| 370 | + ) |
| 371 | + |
| 372 | + await expect(result).rejects.toThrowError(certError) |
| 373 | + expect(mockRequestFn).toHaveBeenCalledTimes(1) |
| 374 | + }), |
| 375 | + ) |
| 376 | + }) |
| 377 | +}) |
| 378 | + |
| 379 | +describe('isTransientNetworkError', () => { |
| 380 | + test('identifies transient network errors that should be retried', () => { |
| 381 | + const transientErrors = [ |
| 382 | + 'socket hang up', |
| 383 | + 'ECONNRESET', |
| 384 | + 'ECONNABORTED', |
| 385 | + 'ENOTFOUND', |
| 386 | + 'ENETUNREACH', |
| 387 | + 'network socket disconnected', |
| 388 | + 'ETIMEDOUT', |
| 389 | + 'ECONNREFUSED', |
| 390 | + 'EAI_AGAIN', |
| 391 | + 'EPIPE', |
| 392 | + 'the operation was aborted', |
| 393 | + 'timeout occurred', |
| 394 | + 'premature close', |
| 395 | + 'getaddrinfo ENOTFOUND', |
| 396 | + ] |
| 397 | + |
| 398 | + for (const errorMsg of transientErrors) { |
| 399 | + expect(isTransientNetworkError(new Error(errorMsg))).toBe(true) |
| 400 | + } |
| 401 | + }) |
| 402 | + |
| 403 | + test('identifies blank reason network errors', () => { |
| 404 | + const blankReasonErrors = [ |
| 405 | + 'request to https://example.com failed, reason:', |
| 406 | + 'request to https://example.com failed, reason: ', |
| 407 | + 'request to https://example.com failed, reason:\n\t', |
| 408 | + ] |
| 409 | + |
| 410 | + for (const errorMsg of blankReasonErrors) { |
| 411 | + expect(isTransientNetworkError(new Error(errorMsg))).toBe(true) |
| 412 | + } |
| 413 | + }) |
| 414 | + |
| 415 | + test('does not identify certificate errors as transient (should not be retried)', () => { |
| 416 | + const permanentErrors = [ |
| 417 | + 'certificate has expired', |
| 418 | + 'cert verification failed', |
| 419 | + 'TLS handshake failed', |
| 420 | + 'SSL certificate problem', |
| 421 | + "Hostname/IP does not match certificate's altnames", |
| 422 | + ] |
| 423 | + |
| 424 | + for (const errorMsg of permanentErrors) { |
| 425 | + expect(isTransientNetworkError(new Error(errorMsg))).toBe(false) |
| 426 | + } |
| 427 | + }) |
| 428 | + |
| 429 | + test('does not identify non-network errors as transient', () => { |
| 430 | + const nonNetworkErrors = [ |
| 431 | + 'Invalid JSON', |
| 432 | + 'Syntax error', |
| 433 | + 'undefined is not a function', |
| 434 | + 'request failed with status 500', |
| 435 | + ] |
| 436 | + |
| 437 | + for (const errorMsg of nonNetworkErrors) { |
| 438 | + expect(isTransientNetworkError(new Error(errorMsg))).toBe(false) |
| 439 | + } |
| 440 | + }) |
| 441 | + |
| 442 | + test('returns false for non-Error objects', () => { |
| 443 | + expect(isTransientNetworkError('string error')).toBe(false) |
| 444 | + expect(isTransientNetworkError(null)).toBe(false) |
| 445 | + expect(isTransientNetworkError(undefined)).toBe(false) |
| 446 | + expect(isTransientNetworkError({message: 'ENOTFOUND'})).toBe(false) |
| 447 | + }) |
| 448 | +}) |
| 449 | + |
| 450 | +describe('isNetworkError', () => { |
| 451 | + test('identifies all transient network errors', () => { |
| 452 | + const transientErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'socket hang up', 'premature close'] |
| 453 | + |
| 454 | + for (const errorMsg of transientErrors) { |
| 455 | + expect(isNetworkError(new Error(errorMsg))).toBe(true) |
| 456 | + } |
| 457 | + }) |
| 458 | + |
| 459 | + test('identifies permanent network errors (certificate/TLS/SSL)', () => { |
| 460 | + const permanentErrors = [ |
| 461 | + 'certificate has expired', |
| 462 | + 'cert verification failed', |
| 463 | + 'TLS handshake failed', |
| 464 | + 'SSL certificate problem', |
| 465 | + "Hostname/IP does not match certificate's altnames", |
| 466 | + 'unable to verify the first certificate', |
| 467 | + 'self signed certificate in certificate chain', |
| 468 | + ] |
| 469 | + |
| 470 | + for (const errorMsg of permanentErrors) { |
| 471 | + expect(isNetworkError(new Error(errorMsg))).toBe(true) |
| 472 | + } |
| 473 | + }) |
| 474 | + |
| 475 | + test('does not identify non-network errors', () => { |
| 476 | + const nonNetworkErrors = [ |
| 477 | + 'Invalid JSON', |
| 478 | + 'Syntax error', |
| 479 | + 'undefined is not a function', |
| 480 | + 'request failed with status 500', |
| 481 | + ] |
| 482 | + |
| 483 | + for (const errorMsg of nonNetworkErrors) { |
| 484 | + expect(isNetworkError(new Error(errorMsg))).toBe(false) |
| 485 | + } |
| 486 | + }) |
| 487 | + |
| 488 | + test('returns false for non-Error objects', () => { |
| 489 | + expect(isNetworkError('string error')).toBe(false) |
| 490 | + expect(isNetworkError(null)).toBe(false) |
| 491 | + expect(isNetworkError(undefined)).toBe(false) |
| 492 | + expect(isNetworkError({message: 'certificate error'})).toBe(false) |
| 493 | + }) |
345 | 494 | }) |
0 commit comments