Skip to content

Commit 12700d5

Browse files
authored
Merge pull request #3563 from iclanton/runWithRetriesAsync
[node-core-library] Add an Async.runWithRetriesAsync API to run and a retry an async function that may intermittently fail.
2 parents c0f11c6 + 7333084 commit 12700d5

File tree

6 files changed

+211
-1
lines changed

6 files changed

+211
-1
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/node-core-library",
5+
"comment": "Add an Async.runWithRetriesAsync() API to run and a retry an async function that may intermittently fail.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/node-core-library"
10+
}

common/reviews/api/node-core-library.api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export class AnsiEscape {
3333
export class Async {
3434
static forEachAsync<TEntry>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<void>, options?: IAsyncParallelismOptions | undefined): Promise<void>;
3535
static mapAsync<TEntry, TRetVal>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<TRetVal>, options?: IAsyncParallelismOptions | undefined): Promise<TRetVal[]>;
36+
static runWithRetriesAsync<TResult>({ action, maxRetries, retryDelayMs }: IRunWithRetriesOptions<TResult>): Promise<TResult>;
3637
static sleep(ms: number): Promise<void>;
3738
}
3839

@@ -595,6 +596,16 @@ export interface IProtectableMapParameters<K, V> {
595596
onSet?: (source: ProtectableMap<K, V>, key: K, value: V) => V;
596597
}
597598

599+
// @beta (undocumented)
600+
export interface IRunWithRetriesOptions<TResult> {
601+
// (undocumented)
602+
action: () => Promise<TResult> | TResult;
603+
// (undocumented)
604+
maxRetries: number;
605+
// (undocumented)
606+
retryDelayMs?: number;
607+
}
608+
598609
// @beta (undocumented)
599610
export interface IStringBufferOutputOptions {
600611
normalizeSpecialCharacters: boolean;

libraries/node-core-library/src/Async.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ export interface IAsyncParallelismOptions {
1717
concurrency?: number;
1818
}
1919

20+
/**
21+
* @remarks
22+
* Used with {@link Async.runWithRetriesAsync}.
23+
*
24+
* @beta
25+
*/
26+
export interface IRunWithRetriesOptions<TResult> {
27+
action: () => Promise<TResult> | TResult;
28+
maxRetries: number;
29+
retryDelayMs?: number;
30+
}
31+
2032
/**
2133
* Utilities for parallel asynchronous operations, for use with the system `Promise` APIs.
2234
*
@@ -154,4 +166,27 @@ export class Async {
154166
setTimeout(resolve, ms);
155167
});
156168
}
169+
170+
/**
171+
* Executes an async function and optionally retries it if it fails.
172+
*/
173+
public static async runWithRetriesAsync<TResult>({
174+
action,
175+
maxRetries,
176+
retryDelayMs = 0
177+
}: IRunWithRetriesOptions<TResult>): Promise<TResult> {
178+
let retryCounter: number = 0;
179+
// eslint-disable-next-line no-constant-condition
180+
while (true) {
181+
try {
182+
return await action();
183+
} catch (e) {
184+
if (++retryCounter > maxRetries) {
185+
throw e;
186+
} else if (retryDelayMs > 0) {
187+
await Async.sleep(retryDelayMs);
188+
}
189+
}
190+
}
191+
}
157192
}

libraries/node-core-library/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
export { AlreadyReportedError } from './AlreadyReportedError';
1111
export { AnsiEscape, IAnsiEscapeConvertForTestsOptions } from './Terminal/AnsiEscape';
12-
export { Async, IAsyncParallelismOptions } from './Async';
12+
export { Async, IAsyncParallelismOptions, IRunWithRetriesOptions } from './Async';
1313
export { Brand } from './PrimitiveTypes';
1414
export { FileConstants, FolderConstants } from './Constants';
1515
export { Enum } from './Enum';

libraries/node-core-library/src/test/Async.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,149 @@ describe(Async.name, () => {
313313
).rejects.toThrow(expectedError);
314314
});
315315
});
316+
317+
describe(Async.runWithRetriesAsync.name, () => {
318+
it('Correctly handles a sync function that succeeds the first time', async () => {
319+
const expectedResult: string = 'RESULT';
320+
const result: string = await Async.runWithRetriesAsync({ action: () => expectedResult, maxRetries: 0 });
321+
expect(result).toEqual(expectedResult);
322+
});
323+
324+
it('Correctly handles an async function that succeeds the first time', async () => {
325+
const expectedResult: string = 'RESULT';
326+
const result: string = await Async.runWithRetriesAsync({
327+
action: async () => expectedResult,
328+
maxRetries: 0
329+
});
330+
expect(result).toEqual(expectedResult);
331+
});
332+
333+
it('Correctly handles a sync function that throws and does not allow retries', async () => {
334+
await expect(
335+
async () =>
336+
await Async.runWithRetriesAsync({
337+
action: () => {
338+
throw new Error('error');
339+
},
340+
maxRetries: 0
341+
})
342+
).rejects.toThrowErrorMatchingSnapshot();
343+
});
344+
345+
it('Correctly handles an async function that throws and does not allow retries', async () => {
346+
await expect(
347+
async () =>
348+
await Async.runWithRetriesAsync({
349+
action: async () => {
350+
throw new Error('error');
351+
},
352+
maxRetries: 0
353+
})
354+
).rejects.toThrowErrorMatchingSnapshot();
355+
});
356+
357+
it('Correctly handles a sync function that always throws and allows several retries', async () => {
358+
await expect(
359+
async () =>
360+
await Async.runWithRetriesAsync({
361+
action: () => {
362+
throw new Error('error');
363+
},
364+
maxRetries: 5
365+
})
366+
).rejects.toThrowErrorMatchingSnapshot();
367+
});
368+
369+
it('Correctly handles an async function that always throws and allows several retries', async () => {
370+
await expect(
371+
async () =>
372+
await Async.runWithRetriesAsync({
373+
action: async () => {
374+
throw new Error('error');
375+
},
376+
maxRetries: 5
377+
})
378+
).rejects.toThrowErrorMatchingSnapshot();
379+
});
380+
381+
it('Correctly handles a sync function that throws once and then succeeds', async () => {
382+
const expectedResult: string = 'RESULT';
383+
let callCount: number = 0;
384+
const result: string = await Async.runWithRetriesAsync({
385+
action: () => {
386+
if (callCount++ === 0) {
387+
throw new Error('error');
388+
} else {
389+
return expectedResult;
390+
}
391+
},
392+
maxRetries: 1
393+
});
394+
expect(result).toEqual(expectedResult);
395+
});
396+
397+
it('Correctly handles an async function that throws once and then succeeds', async () => {
398+
const expectedResult: string = 'RESULT';
399+
let callCount: number = 0;
400+
const result: string = await Async.runWithRetriesAsync({
401+
action: () => {
402+
if (callCount++ === 0) {
403+
throw new Error('error');
404+
} else {
405+
return expectedResult;
406+
}
407+
},
408+
maxRetries: 1
409+
});
410+
expect(result).toEqual(expectedResult);
411+
});
412+
413+
it('Correctly handles a sync function that throws once and then succeeds with a timeout', async () => {
414+
const expectedResult: string = 'RESULT';
415+
let callCount: number = 0;
416+
const sleepSpy: jest.SpyInstance = jest
417+
.spyOn(Async, 'sleep')
418+
.mockImplementation(() => Promise.resolve());
419+
420+
const resultPromise: Promise<string> = Async.runWithRetriesAsync({
421+
action: () => {
422+
if (callCount++ === 0) {
423+
throw new Error('error');
424+
} else {
425+
return expectedResult;
426+
}
427+
},
428+
maxRetries: 1,
429+
retryDelayMs: 5
430+
});
431+
432+
expect(await resultPromise).toEqual(expectedResult);
433+
expect(sleepSpy).toHaveBeenCalledTimes(1);
434+
expect(sleepSpy).toHaveBeenLastCalledWith(5);
435+
});
436+
437+
it('Correctly handles an async function that throws once and then succeeds with a timeout', async () => {
438+
const expectedResult: string = 'RESULT';
439+
let callCount: number = 0;
440+
const sleepSpy: jest.SpyInstance = jest
441+
.spyOn(Async, 'sleep')
442+
.mockImplementation(() => Promise.resolve());
443+
444+
const resultPromise: Promise<string> = Async.runWithRetriesAsync({
445+
action: async () => {
446+
if (callCount++ === 0) {
447+
throw new Error('error');
448+
} else {
449+
return expectedResult;
450+
}
451+
},
452+
maxRetries: 1,
453+
retryDelayMs: 5
454+
});
455+
456+
expect(await resultPromise).toEqual(expectedResult);
457+
expect(sleepSpy).toHaveBeenCalledTimes(1);
458+
expect(sleepSpy).toHaveBeenLastCalledWith(5);
459+
});
460+
});
316461
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Async runWithRetriesAsync Correctly handles a sync function that always throws and allows several retries 1`] = `"error"`;
4+
5+
exports[`Async runWithRetriesAsync Correctly handles a sync function that throws and does not allow retries 1`] = `"error"`;
6+
7+
exports[`Async runWithRetriesAsync Correctly handles an async function that always throws and allows several retries 1`] = `"error"`;
8+
9+
exports[`Async runWithRetriesAsync Correctly handles an async function that throws and does not allow retries 1`] = `"error"`;

0 commit comments

Comments
 (0)