Skip to content

Commit 9d47b60

Browse files
committed
Harden retry timing and callback validation
1 parent f011d2e commit 9d47b60

4 files changed

Lines changed: 87 additions & 23 deletions

File tree

index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type RetryContext = {
2222
This is calculated based on `minTimeout`, `factor`, `maxTimeout`, and `randomize` options.
2323
2424
Note: The actual delay may be shorter if it would exceed `maxRetryTime`.
25-
This is `0` when the retry is skipped or when no retry will occur.
25+
This is `0` when the retry is skipped or when no retry will occur based on the checks completed before the current callback runs.
2626
*/
2727
readonly retryDelay: number;
2828
};

index.js

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ function validateNumberOption(name, value, {min = 0, allowInfinity = false} = {}
3232
}
3333
}
3434

35+
function validateFunctionOption(name, value) {
36+
if (value === undefined) {
37+
return;
38+
}
39+
40+
if (typeof value !== 'function') {
41+
throw new TypeError(`Expected \`${name}\` to be a function.`);
42+
}
43+
}
44+
3545
export class AbortError extends Error {
3646
constructor(message) {
3747
super();
@@ -67,6 +77,31 @@ function calculateRemainingTime(start, max) {
6777
return max - (performance.now() - start);
6878
}
6979

80+
async function delayForRetry(delay, options) {
81+
if (delay <= 0) {
82+
return;
83+
}
84+
85+
await new Promise((resolve, reject) => {
86+
const onAbort = () => {
87+
clearTimeout(timeoutToken);
88+
options.signal?.removeEventListener('abort', onAbort);
89+
reject(options.signal.reason);
90+
};
91+
92+
const timeoutToken = setTimeout(() => {
93+
options.signal?.removeEventListener('abort', onAbort);
94+
resolve();
95+
}, delay);
96+
97+
if (options.unref) {
98+
timeoutToken.unref?.();
99+
}
100+
101+
options.signal?.addEventListener('abort', onAbort, {once: true});
102+
});
103+
}
104+
70105
async function onAttemptFailure({error, attemptNumber, retriesConsumed, startTime, options}) {
71106
const normalizedError = error instanceof Error
72107
? error
@@ -136,35 +171,22 @@ async function onAttemptFailure({error, attemptNumber, retriesConsumed, startTim
136171
throw normalizedError;
137172
}
138173

174+
const remainingTimeAfterShouldRetry = calculateRemainingTime(startTime, maxRetryTime);
175+
176+
if (remainingTimeAfterShouldRetry <= 0) {
177+
throw normalizedError;
178+
}
179+
139180
if (!consumeRetry) {
140181
options.signal?.throwIfAborted();
141182
return false;
142183
}
143184

144-
const finalDelay = Math.min(effectiveDelay, remainingTime);
185+
const finalDelay = Math.min(effectiveDelay, remainingTimeAfterShouldRetry);
145186

146187
options.signal?.throwIfAborted();
147188

148-
if (finalDelay > 0) {
149-
await new Promise((resolve, reject) => {
150-
const onAbort = () => {
151-
clearTimeout(timeoutToken);
152-
options.signal?.removeEventListener('abort', onAbort);
153-
reject(options.signal.reason);
154-
};
155-
156-
const timeoutToken = setTimeout(() => {
157-
options.signal?.removeEventListener('abort', onAbort);
158-
resolve();
159-
}, finalDelay);
160-
161-
if (options.unref) {
162-
timeoutToken.unref?.();
163-
}
164-
165-
options.signal?.addEventListener('abort', onAbort, {once: true});
166-
});
167-
}
189+
await delayForRetry(finalDelay, options);
168190

169191
options.signal?.throwIfAborted();
170192

@@ -191,6 +213,9 @@ export default async function pRetry(input, options = {}) {
191213
options.shouldConsumeRetry ??= () => true;
192214

193215
// Validate numeric options and normalize edge cases
216+
validateFunctionOption('onFailedAttempt', options.onFailedAttempt);
217+
validateFunctionOption('shouldRetry', options.shouldRetry);
218+
validateFunctionOption('shouldConsumeRetry', options.shouldConsumeRetry);
194219
validateNumberOption('factor', options.factor, {min: 0, allowInfinity: false});
195220
validateNumberOption('minTimeout', options.minTimeout, {min: 0, allowInfinity: false});
196221
validateNumberOption('maxTimeout', options.maxTimeout, {min: 0, allowInfinity: true});

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ The `context` object contains:
9090
- `attemptNumber` - The attempt number (starts at 1)
9191
- `retriesLeft` - Number of retries remaining
9292
- `retriesConsumed` - Number of retries consumed so far
93-
- `retryDelay` - The delay in milliseconds before the next retry (based on `minTimeout`, `factor`, `maxTimeout`, and `randomize`). This is `0` when the retry is skipped or when no retry will occur.
93+
- `retryDelay` - The delay in milliseconds before the next retry (based on `minTimeout`, `factor`, `maxTimeout`, and `randomize`). This is `0` when the retry is skipped or when no retry will occur based on the checks completed before the current callback runs.
9494

9595
The `onFailedAttempt` function can return a promise. For example, to add a [delay](https://github.com/sindresorhus/delay):
9696

test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,25 @@ test('shouldRetry is not called for non-network TypeError', async t => {
8282
t.is(shouldRetryCalled, 0);
8383
});
8484

85+
test('async shouldRetry respects maxRetryTime after callback', async t => {
86+
let attempts = 0;
87+
88+
await t.throwsAsync(pRetry(async () => {
89+
attempts++;
90+
throw new Error('fail');
91+
}, {
92+
retries: 5,
93+
maxRetryTime: 50,
94+
minTimeout: 0,
95+
async shouldRetry() {
96+
await delay(100);
97+
return true;
98+
},
99+
}));
100+
101+
t.is(attempts, 1);
102+
});
103+
85104
test('does not retry non-network TypeError when shouldConsumeRetry returns false', async t => {
86105
const typeErrorFixture = new TypeError('type-error-fixture');
87106
let attempts = 0;
@@ -100,6 +119,26 @@ test('does not retry non-network TypeError when shouldConsumeRetry returns false
100119
t.is(attempts, 1);
101120
});
102121

122+
test('throws on invalid callback options', async t => {
123+
await t.throwsAsync(pRetry(async () => {}, {
124+
onFailedAttempt: 1,
125+
}), {
126+
message: 'Expected `onFailedAttempt` to be a function.',
127+
});
128+
129+
await t.throwsAsync(pRetry(async () => {}, {
130+
shouldRetry: 1,
131+
}), {
132+
message: 'Expected `shouldRetry` to be a function.',
133+
});
134+
135+
await t.throwsAsync(pRetry(async () => {}, {
136+
shouldConsumeRetry: 1,
137+
}), {
138+
message: 'Expected `shouldConsumeRetry` to be a function.',
139+
});
140+
});
141+
103142
test('retry on TypeError - failed to fetch', async t => {
104143
const typeErrorFixture = new TypeError('Failed to fetch');
105144
let index = 0;

0 commit comments

Comments
 (0)