-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathbilling.retry.unit.tests.js
More file actions
93 lines (85 loc) · 3.62 KB
/
Copy pathbilling.retry.unit.tests.js
File metadata and controls
93 lines (85 loc) · 3.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
* Module dependencies.
*/
import { jest, describe, test, afterEach, expect } from '@jest/globals';
import { retryWithBackoff } from '../lib/billing.retry.js';
/**
* Unit tests for retryWithBackoff:
* - success / transient-retry / exhaustion paths
* - shouldRetry short-circuit on non-transient (deterministic) errors
* - argument validation
* Retry-delay paths use fake timers so the suite never incurs real backoff waits.
*/
describe('retryWithBackoff', () => {
afterEach(() => {
jest.useRealTimers();
});
test('returns the result on first success without retrying', async () => {
const fn = jest.fn().mockResolvedValue('ok');
await expect(retryWithBackoff(fn)).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});
test('retries a transient failure then resolves', async () => {
jest.useFakeTimers();
const fn = jest.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce('ok');
const promise = retryWithBackoff(fn, { attempts: 3, baseMs: 200 });
await jest.runAllTimersAsync();
await expect(promise).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
test('throws the last error after exhausting all attempts', async () => {
jest.useFakeTimers();
const fn = jest.fn().mockRejectedValue(new Error('always fails'));
const promise = retryWithBackoff(fn, { attempts: 3, baseMs: 200 });
const assertion = expect(promise).rejects.toThrow('always fails');
await jest.runAllTimersAsync();
await assertion;
expect(fn).toHaveBeenCalledTimes(3);
});
test('short-circuits (1 call, not 3) when shouldRetry returns false', async () => {
const err = Object.assign(new Error('bad params'), { type: 'invalid_request_error' });
const fn = jest.fn().mockRejectedValue(err);
await expect(
retryWithBackoff(fn, {
attempts: 3,
baseMs: 200,
shouldRetry: (e) => e?.type !== 'StripeInvalidRequestError' && e?.type !== 'invalid_request_error',
}),
).rejects.toThrow('bad params');
expect(fn).toHaveBeenCalledTimes(1);
});
test('short-circuits on the StripeInvalidRequestError class-name type too', async () => {
// stripe-node exposes the error class name on .type in some SDK versions and the
// raw API type string in others; the call-site predicate guards both, so cover both.
const err = Object.assign(new Error('bad params'), { type: 'StripeInvalidRequestError' });
const fn = jest.fn().mockRejectedValue(err);
await expect(
retryWithBackoff(fn, {
attempts: 3,
baseMs: 200,
shouldRetry: (e) => e?.type !== 'StripeInvalidRequestError' && e?.type !== 'invalid_request_error',
}),
).rejects.toThrow('bad params');
expect(fn).toHaveBeenCalledTimes(1);
});
test('retries to exhaustion when shouldRetry returns true', async () => {
jest.useFakeTimers();
const fn = jest.fn().mockRejectedValue(new Error('transient'));
const promise = retryWithBackoff(fn, {
attempts: 3,
baseMs: 200,
shouldRetry: () => true,
});
const assertion = expect(promise).rejects.toThrow('transient');
await jest.runAllTimersAsync();
await assertion;
expect(fn).toHaveBeenCalledTimes(3);
});
test('rejects invalid attempts / baseMs / shouldRetry with TypeError', async () => {
const fn = jest.fn().mockResolvedValue('ok');
await expect(retryWithBackoff(fn, { attempts: 0 })).rejects.toThrow(TypeError);
await expect(retryWithBackoff(fn, { baseMs: -1 })).rejects.toThrow(TypeError);
await expect(retryWithBackoff(fn, { shouldRetry: 'nope' })).rejects.toThrow(TypeError);
expect(fn).not.toHaveBeenCalled();
});
});