opticedge-cloud-utils 1.1.6 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +106 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/retry.ts +129 -0
- package/tests/retry.test.ts +298 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ __exportStar(require("./auth"), exports);
|
|
|
23
23
|
__exportStar(require("./env"), exports);
|
|
24
24
|
__exportStar(require("./parser"), exports);
|
|
25
25
|
__exportStar(require("./regex"), exports);
|
|
26
|
+
__exportStar(require("./retry"), exports);
|
|
26
27
|
__exportStar(require("./secrets"), exports);
|
|
27
28
|
__exportStar(require("./task"), exports);
|
|
28
29
|
__exportStar(require("./validator"), exports);
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type RetryOptions = {
|
|
2
|
+
retries?: number;
|
|
3
|
+
baseDelayMs?: number;
|
|
4
|
+
maxDelayMs?: number;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
signal?: AbortSignal | null;
|
|
7
|
+
isRetryable?: (err: any) => boolean;
|
|
8
|
+
onRetry?: (err: any, attempt: number, nextDelayMs: number) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function isRetryableDefault(err: any): boolean;
|
|
11
|
+
export declare function retry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isRetryableDefault = isRetryableDefault;
|
|
4
|
+
exports.retry = retry;
|
|
5
|
+
function sleep(ms, signal) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
if (signal?.aborted)
|
|
8
|
+
return reject(new Error('Aborted'));
|
|
9
|
+
const t = setTimeout(() => {
|
|
10
|
+
signal?.removeEventListener('abort', onAbort);
|
|
11
|
+
resolve();
|
|
12
|
+
}, ms);
|
|
13
|
+
function onAbort() {
|
|
14
|
+
clearTimeout(t);
|
|
15
|
+
reject(new Error('Aborted'));
|
|
16
|
+
}
|
|
17
|
+
signal?.addEventListener('abort', onAbort);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function isRetryableDefault(err) {
|
|
21
|
+
if (!err)
|
|
22
|
+
return false;
|
|
23
|
+
// gRPC numeric code for UNAVAILABLE is 14
|
|
24
|
+
if (err?.code === 14 || err?.grpcCode === 14)
|
|
25
|
+
return true;
|
|
26
|
+
// Common Node network error codes
|
|
27
|
+
const syscode = String(err?.code ?? '').toLowerCase();
|
|
28
|
+
if (syscode === 'econnreset' ||
|
|
29
|
+
syscode === 'etimedout' ||
|
|
30
|
+
syscode === 'econnrefused' ||
|
|
31
|
+
syscode === 'enotfound' ||
|
|
32
|
+
syscode === 'enetunreach' ||
|
|
33
|
+
syscode === 'eai_again' ||
|
|
34
|
+
syscode === 'ehostunreach' ||
|
|
35
|
+
syscode === 'epipe') {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
// HTTP/Fetch style errors (axios / fetch)
|
|
39
|
+
const status = err?.response?.status ?? err?.status;
|
|
40
|
+
if (typeof status === 'number') {
|
|
41
|
+
// retry on 429 (rate limit) and 5xx (server errors)
|
|
42
|
+
if (status === 429 || (status >= 500 && status < 600))
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
// Message-based heuristics: include variants like "timed out" as well as "timeout"
|
|
46
|
+
const msg = String(err?.message ?? '').toLowerCase();
|
|
47
|
+
// Check for common retryable phrases:
|
|
48
|
+
// - timeout, timed out, time out
|
|
49
|
+
// - temporary
|
|
50
|
+
// - unavailable
|
|
51
|
+
if (/(timeout|timed out|time out|temporary|unavailable)/.test(msg))
|
|
52
|
+
return true;
|
|
53
|
+
// Also accept explicit substrings for network-errno-like messages
|
|
54
|
+
if (msg.includes('econnreset') || msg.includes('etimedout'))
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
async function retry(fn, opts = {}) {
|
|
59
|
+
const retries = opts.retries ?? 4;
|
|
60
|
+
const base = opts.baseDelayMs ?? 200;
|
|
61
|
+
const max = opts.maxDelayMs ?? 5000;
|
|
62
|
+
const timeoutMs = opts.timeoutMs;
|
|
63
|
+
const signal = opts.signal ?? null;
|
|
64
|
+
const isRetryable = opts.isRetryable ?? isRetryableDefault;
|
|
65
|
+
const onRetry = opts.onRetry;
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
let attempt = 0;
|
|
68
|
+
let lastErr;
|
|
69
|
+
/* eslint-disable no-constant-condition */
|
|
70
|
+
while (true) {
|
|
71
|
+
if (signal?.aborted)
|
|
72
|
+
throw new Error('Aborted');
|
|
73
|
+
try {
|
|
74
|
+
return await fn();
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
lastErr = err;
|
|
78
|
+
if (!isRetryable(err)) {
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
if (attempt >= retries) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
attempt++;
|
|
85
|
+
const exp = Math.min(max, base * Math.pow(2, attempt - 1));
|
|
86
|
+
const delay = Math.floor(Math.random() * exp);
|
|
87
|
+
if (typeof timeoutMs === 'number') {
|
|
88
|
+
const elapsed = Date.now() - start;
|
|
89
|
+
if (elapsed + delay > timeoutMs) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
onRetry?.(err, attempt, delay);
|
|
95
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
// ignore errors from onRetry
|
|
99
|
+
}
|
|
100
|
+
await sleep(delay, signal);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const finalErr = new Error(`Retry failed after ${attempt} retries: ${String(lastErr?.message ?? lastErr)}`);
|
|
104
|
+
finalErr.original = lastErr;
|
|
105
|
+
throw finalErr;
|
|
106
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/retry.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// src/retry.ts
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
export type RetryOptions = {
|
|
4
|
+
retries?: number
|
|
5
|
+
baseDelayMs?: number
|
|
6
|
+
maxDelayMs?: number
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
signal?: AbortSignal | null
|
|
9
|
+
isRetryable?: (err: any) => boolean
|
|
10
|
+
onRetry?: (err: any, attempt: number, nextDelayMs: number) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sleep(ms: number, signal?: AbortSignal | null) {
|
|
14
|
+
return new Promise<void>((resolve, reject) => {
|
|
15
|
+
if (signal?.aborted) return reject(new Error('Aborted'))
|
|
16
|
+
const t = setTimeout(() => {
|
|
17
|
+
signal?.removeEventListener('abort', onAbort)
|
|
18
|
+
resolve()
|
|
19
|
+
}, ms)
|
|
20
|
+
function onAbort() {
|
|
21
|
+
clearTimeout(t)
|
|
22
|
+
reject(new Error('Aborted'))
|
|
23
|
+
}
|
|
24
|
+
signal?.addEventListener('abort', onAbort)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isRetryableDefault(err: any): boolean {
|
|
29
|
+
if (!err) return false
|
|
30
|
+
|
|
31
|
+
// gRPC numeric code for UNAVAILABLE is 14
|
|
32
|
+
if (err?.code === 14 || err?.grpcCode === 14) return true
|
|
33
|
+
|
|
34
|
+
// Common Node network error codes
|
|
35
|
+
const syscode = String(err?.code ?? '').toLowerCase()
|
|
36
|
+
if (
|
|
37
|
+
syscode === 'econnreset' ||
|
|
38
|
+
syscode === 'etimedout' ||
|
|
39
|
+
syscode === 'econnrefused' ||
|
|
40
|
+
syscode === 'enotfound' ||
|
|
41
|
+
syscode === 'enetunreach' ||
|
|
42
|
+
syscode === 'eai_again' ||
|
|
43
|
+
syscode === 'ehostunreach' ||
|
|
44
|
+
syscode === 'epipe'
|
|
45
|
+
) {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// HTTP/Fetch style errors (axios / fetch)
|
|
50
|
+
const status = err?.response?.status ?? err?.status
|
|
51
|
+
if (typeof status === 'number') {
|
|
52
|
+
// retry on 429 (rate limit) and 5xx (server errors)
|
|
53
|
+
if (status === 429 || (status >= 500 && status < 600)) return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Message-based heuristics: include variants like "timed out" as well as "timeout"
|
|
57
|
+
const msg = String(err?.message ?? '').toLowerCase()
|
|
58
|
+
|
|
59
|
+
// Check for common retryable phrases:
|
|
60
|
+
// - timeout, timed out, time out
|
|
61
|
+
// - temporary
|
|
62
|
+
// - unavailable
|
|
63
|
+
if (/(timeout|timed out|time out|temporary|unavailable)/.test(msg)) return true
|
|
64
|
+
|
|
65
|
+
// Also accept explicit substrings for network-errno-like messages
|
|
66
|
+
if (msg.includes('econnreset') || msg.includes('etimedout')) return true
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
|
|
72
|
+
const retries = opts.retries ?? 4
|
|
73
|
+
const base = opts.baseDelayMs ?? 200
|
|
74
|
+
const max = opts.maxDelayMs ?? 5000
|
|
75
|
+
const timeoutMs = opts.timeoutMs
|
|
76
|
+
const signal = opts.signal ?? null
|
|
77
|
+
const isRetryable = opts.isRetryable ?? isRetryableDefault
|
|
78
|
+
const onRetry = opts.onRetry
|
|
79
|
+
|
|
80
|
+
const start = Date.now()
|
|
81
|
+
|
|
82
|
+
let attempt = 0
|
|
83
|
+
let lastErr: any
|
|
84
|
+
|
|
85
|
+
/* eslint-disable no-constant-condition */
|
|
86
|
+
while (true) {
|
|
87
|
+
if (signal?.aborted) throw new Error('Aborted')
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return await fn()
|
|
91
|
+
} catch (err) {
|
|
92
|
+
lastErr = err
|
|
93
|
+
|
|
94
|
+
if (!isRetryable(err)) {
|
|
95
|
+
throw err
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (attempt >= retries) {
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
attempt++
|
|
103
|
+
const exp = Math.min(max, base * Math.pow(2, attempt - 1))
|
|
104
|
+
const delay = Math.floor(Math.random() * exp)
|
|
105
|
+
|
|
106
|
+
if (typeof timeoutMs === 'number') {
|
|
107
|
+
const elapsed = Date.now() - start
|
|
108
|
+
if (elapsed + delay > timeoutMs) {
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
onRetry?.(err, attempt, delay)
|
|
115
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// ignore errors from onRetry
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await sleep(delay, signal)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const finalErr = new Error(
|
|
125
|
+
`Retry failed after ${attempt} retries: ${String(lastErr?.message ?? lastErr)}`
|
|
126
|
+
)
|
|
127
|
+
;(finalErr as any).original = lastErr
|
|
128
|
+
throw finalErr
|
|
129
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// tests/retry.test.ts
|
|
3
|
+
import { retry, isRetryableDefault } from '../src'
|
|
4
|
+
|
|
5
|
+
describe('isRetryableDefault', () => {
|
|
6
|
+
test('returns false for falsy errors (covers `if (!err) return false`)', () => {
|
|
7
|
+
expect(isRetryableDefault(null)).toBe(false)
|
|
8
|
+
expect(isRetryableDefault(undefined)).toBe(false)
|
|
9
|
+
// Also check a falsy-but-not-nullish value is handled (should coerce to false at top)
|
|
10
|
+
expect(isRetryableDefault(0 as unknown as any)).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('returns false for an object with no retryable properties', () => {
|
|
14
|
+
expect(isRetryableDefault({})).toBe(false)
|
|
15
|
+
expect(isRetryableDefault({ foo: 'bar' })).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('matches message containing "timeout" (case-insensitive)', () => {
|
|
19
|
+
expect(isRetryableDefault(new Error('Request timed out'))).toBe(true)
|
|
20
|
+
expect(isRetryableDefault(new Error('TIMEOUT occurred'))).toBe(true)
|
|
21
|
+
expect(isRetryableDefault({ message: 'this is a timeout error' })).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('matches message containing "temporary"', () => {
|
|
25
|
+
expect(isRetryableDefault(new Error('Temporary failure contacting host'))).toBe(true)
|
|
26
|
+
expect(isRetryableDefault({ message: 'temporary issue' })).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('matches message containing "unavailable"', () => {
|
|
30
|
+
expect(isRetryableDefault(new Error('Service Unavailable'))).toBe(true)
|
|
31
|
+
expect(isRetryableDefault({ message: 'unavailable resource' })).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('matches message containing "econnreset" or "etimedout" (substrings, case-insensitive)', () => {
|
|
35
|
+
expect(isRetryableDefault(new Error('ECONNRESET by peer'))).toBe(true)
|
|
36
|
+
expect(isRetryableDefault(new Error('econnreset'))).toBe(true)
|
|
37
|
+
expect(isRetryableDefault(new Error('ETIMEDOUT waiting for response'))).toBe(true)
|
|
38
|
+
expect(isRetryableDefault({ message: 'some ETIMEDOUT occurred' })).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('does not treat unrelated messages as retryable', () => {
|
|
42
|
+
expect(isRetryableDefault(new Error('Bad request'))).toBe(false)
|
|
43
|
+
expect(isRetryableDefault({ message: 'invalid parameter provided' })).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('still returns true when message sits alongside other properties (defensive)', () => {
|
|
47
|
+
const errLike: any = {
|
|
48
|
+
code: undefined,
|
|
49
|
+
response: undefined,
|
|
50
|
+
message: 'temporary network hiccup'
|
|
51
|
+
}
|
|
52
|
+
expect(isRetryableDefault(errLike)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('retry util', () => {
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
jest.restoreAllMocks()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('returns result when fn succeeds immediately', async () => {
|
|
62
|
+
const fn = jest.fn(async () => 'ok')
|
|
63
|
+
const res = await retry(fn, { retries: 3 })
|
|
64
|
+
expect(res).toBe('ok')
|
|
65
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('retries on transient error and eventually succeeds; onRetry called expected times', async () => {
|
|
69
|
+
// fail twice with retryable error (gRPC code 14), then succeed
|
|
70
|
+
let calls = 0
|
|
71
|
+
const fn = jest.fn(async () => {
|
|
72
|
+
calls++
|
|
73
|
+
if (calls <= 2) {
|
|
74
|
+
const e: any = new Error('transient-gprc')
|
|
75
|
+
e.code = 14
|
|
76
|
+
throw e
|
|
77
|
+
}
|
|
78
|
+
return 'ok-after-retries'
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const onRetry = jest.fn()
|
|
82
|
+
// Make delays zero by forcing Math.random to 0 so tests are deterministic and fast
|
|
83
|
+
const mathSpy = jest.spyOn(Math, 'random').mockReturnValue(0)
|
|
84
|
+
|
|
85
|
+
const res = await retry(fn, { retries: 5, baseDelayMs: 10, onRetry })
|
|
86
|
+
expect(res).toBe('ok-after-retries')
|
|
87
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
88
|
+
// onRetry called for the 2 transient failures
|
|
89
|
+
expect(onRetry).toHaveBeenCalledTimes(2)
|
|
90
|
+
|
|
91
|
+
mathSpy.mockRestore()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('non-retryable error is rethrown immediately', async () => {
|
|
95
|
+
const fn = jest.fn(async () => {
|
|
96
|
+
const e = new Error('bad request')
|
|
97
|
+
// create non-retryable structure: status 400
|
|
98
|
+
;(e as any).status = 400
|
|
99
|
+
throw e
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
await expect(retry(fn, { retries: 3 })).rejects.toThrow('bad request')
|
|
103
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('exhausts retries and throws final error preserving original', async () => {
|
|
107
|
+
const original = new Error('permanent transient-like')
|
|
108
|
+
;(original as any).code = 14 // make it retryable
|
|
109
|
+
const fn = jest.fn(async () => {
|
|
110
|
+
throw original
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// make delays 0 to run fast
|
|
114
|
+
jest.spyOn(Math, 'random').mockReturnValue(0)
|
|
115
|
+
|
|
116
|
+
await expect(retry(fn, { retries: 2, baseDelayMs: 1 })).rejects.toHaveProperty(
|
|
117
|
+
'original',
|
|
118
|
+
original
|
|
119
|
+
)
|
|
120
|
+
// initial call + 2 retries = 3 attempts
|
|
121
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('isRetryableDefault recognizes common shapes', () => {
|
|
125
|
+
const e1: any = new Error('econnreset happened')
|
|
126
|
+
e1.code = 'ECONNRESET'
|
|
127
|
+
expect(isRetryableDefault(e1)).toBe(true)
|
|
128
|
+
|
|
129
|
+
const e2: any = new Error('http 500')
|
|
130
|
+
e2.response = { status: 500 }
|
|
131
|
+
expect(isRetryableDefault(e2)).toBe(true)
|
|
132
|
+
|
|
133
|
+
const e3: any = new Error('not retryable')
|
|
134
|
+
e3.status = 400
|
|
135
|
+
expect(isRetryableDefault(e3)).toBe(false)
|
|
136
|
+
|
|
137
|
+
const e4: any = new Error('gRPC unavailable')
|
|
138
|
+
e4.code = 14
|
|
139
|
+
expect(isRetryableDefault(e4)).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('abort signal aborts immediately when already aborted', async () => {
|
|
143
|
+
const controller = new AbortController()
|
|
144
|
+
controller.abort()
|
|
145
|
+
const fn = jest.fn(async () => 'should-not-run')
|
|
146
|
+
|
|
147
|
+
await expect(retry(fn, { signal: controller.signal })).rejects.toThrow('Aborted')
|
|
148
|
+
// function should not be called at all because we check signal at loop start
|
|
149
|
+
expect(fn).toHaveBeenCalledTimes(0)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('abort during wait causes rejection', async () => {
|
|
153
|
+
// Function will fail once (retryable), then retry -> sleep -> we abort during sleep.
|
|
154
|
+
let calls = 0
|
|
155
|
+
const fn = jest.fn(async () => {
|
|
156
|
+
calls++
|
|
157
|
+
if (calls === 1) {
|
|
158
|
+
const e: any = new Error('transient')
|
|
159
|
+
e.code = 14
|
|
160
|
+
throw e
|
|
161
|
+
}
|
|
162
|
+
return 'should-not-get-here'
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Make delay large so we can abort during sleep (but we will abort synchronously)
|
|
166
|
+
jest.spyOn(Math, 'random').mockReturnValue(1) // picks the max exp
|
|
167
|
+
const controller = new AbortController()
|
|
168
|
+
|
|
169
|
+
// Start the retry but abort immediately after allowing the utility to start.
|
|
170
|
+
// We do not await retry here yet; create a Promise and then abort and await
|
|
171
|
+
const p = retry(fn, { retries: 3, baseDelayMs: 200, signal: controller.signal })
|
|
172
|
+
|
|
173
|
+
// Abort immediately — this should cause the pending sleep to reject
|
|
174
|
+
controller.abort()
|
|
175
|
+
|
|
176
|
+
await expect(p).rejects.toThrow('Aborted')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('abort during wait causes rejection and triggers onAbort (clears timeout)', async () => {
|
|
180
|
+
// Function will fail once (retryable), then retry -> sleep -> we abort during sleep.
|
|
181
|
+
let calls = 0
|
|
182
|
+
const fn = jest.fn(async () => {
|
|
183
|
+
calls++
|
|
184
|
+
if (calls === 1) {
|
|
185
|
+
const e: any = new Error('transient')
|
|
186
|
+
e.code = 14
|
|
187
|
+
throw e
|
|
188
|
+
}
|
|
189
|
+
return 'should-not-get-here'
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Make sure there is a non-zero delay so sleep schedules a timer
|
|
193
|
+
jest.spyOn(Math, 'random').mockReturnValue(0.5)
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
|
|
196
|
+
// Start the retry (it will throw once, then call sleep)
|
|
197
|
+
const p = retry(fn, { retries: 3, baseDelayMs: 200, signal: controller.signal })
|
|
198
|
+
|
|
199
|
+
// Wait a tick so `sleep` has a chance to attach the abort listener and call setTimeout.
|
|
200
|
+
// Using setTimeout 0 (or small timeout) guarantees the timer and listener are attached.
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
202
|
+
|
|
203
|
+
// Now abort — this should call the onAbort handler inside sleep which clears the timeout
|
|
204
|
+
controller.abort()
|
|
205
|
+
|
|
206
|
+
await expect(p).rejects.toThrow('Aborted')
|
|
207
|
+
|
|
208
|
+
// restore mocks
|
|
209
|
+
;(Math.random as jest.Mock).mockRestore()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('respects timeoutMs and breaks before waiting (covers timeout check)', async () => {
|
|
213
|
+
// Make the wrapped fn throw a retryable error once
|
|
214
|
+
const original = new Error('transient-timeout')
|
|
215
|
+
;(original as any).code = 14 // make it retryable via isRetryableDefault
|
|
216
|
+
const fn = jest.fn(async () => {
|
|
217
|
+
throw original
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Force Math.random to 1 so delay === exp (max for that attempt)
|
|
221
|
+
const mathSpy = jest.spyOn(Math, 'random').mockReturnValue(1)
|
|
222
|
+
|
|
223
|
+
// Use a baseDelayMs larger than timeoutMs so elapsed + delay > timeoutMs triggers the "break"
|
|
224
|
+
await expect(
|
|
225
|
+
retry(fn, {
|
|
226
|
+
retries: 5,
|
|
227
|
+
baseDelayMs: 50, // exp for attempt 1 = 50
|
|
228
|
+
timeoutMs: 10 // 50 > 10 so we should break before sleeping
|
|
229
|
+
})
|
|
230
|
+
).rejects.toHaveProperty('original', original)
|
|
231
|
+
|
|
232
|
+
// fn should only have been called once (no retry/sleep occurred)
|
|
233
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
234
|
+
|
|
235
|
+
mathSpy.mockRestore()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('omitted opts uses default retries and eventually succeeds', async () => {
|
|
239
|
+
// Fail twice with retryable error (gRPC code 14), then succeed
|
|
240
|
+
let calls = 0
|
|
241
|
+
const fn = jest.fn(async () => {
|
|
242
|
+
calls++
|
|
243
|
+
if (calls <= 2) {
|
|
244
|
+
const e: any = new Error('transient')
|
|
245
|
+
e.code = 14
|
|
246
|
+
throw e
|
|
247
|
+
}
|
|
248
|
+
return 'ok-defaults'
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Make delays deterministic & small (Math.random = 0 -> delay = 0)
|
|
252
|
+
jest.spyOn(Math, 'random').mockReturnValue(0)
|
|
253
|
+
|
|
254
|
+
const res = await retry(fn) // <- call WITHOUT opts to exercise default param
|
|
255
|
+
expect(res).toBe('ok-defaults')
|
|
256
|
+
|
|
257
|
+
// Called initial + 2 retries = 3 times
|
|
258
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
259
|
+
;(Math.random as jest.Mock).mockRestore()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('omitted opts exhausts default retries (5 attempts total) and throws', async () => {
|
|
263
|
+
const original = new Error('always-transient')
|
|
264
|
+
;(original as any).code = 14 // make it retryable
|
|
265
|
+
const fn = jest.fn(async () => {
|
|
266
|
+
throw original
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Make delays 0 so test runs fast
|
|
270
|
+
jest.spyOn(Math, 'random').mockReturnValue(0)
|
|
271
|
+
|
|
272
|
+
// Default retries is 4 -> total attempts = 5
|
|
273
|
+
await expect(retry(fn)).rejects.toHaveProperty('original', original)
|
|
274
|
+
expect(fn).toHaveBeenCalledTimes(5)
|
|
275
|
+
;(Math.random as jest.Mock).mockRestore()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('final error message falls back to String(lastErr) when lastErr.message is undefined', async () => {
|
|
279
|
+
// original is a plain object (no .message)
|
|
280
|
+
const original: any = { code: 14, detail: 'plain-object-no-message' }
|
|
281
|
+
|
|
282
|
+
const fn = jest.fn(async () => {
|
|
283
|
+
throw original // will be assigned to lastErr in retry()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// Make delays deterministic & fast
|
|
287
|
+
const mathSpy = jest.spyOn(Math, 'random').mockReturnValue(0)
|
|
288
|
+
|
|
289
|
+
// retries = 1 -> attempt will be 1 in finalErr message
|
|
290
|
+
await expect(retry(fn, { retries: 1, baseDelayMs: 1 })).rejects.toMatchObject({
|
|
291
|
+
message: expect.stringContaining('[object Object]'),
|
|
292
|
+
// Ensure original error was preserved on the thrown final error
|
|
293
|
+
original
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
mathSpy.mockRestore()
|
|
297
|
+
})
|
|
298
|
+
})
|