diff --git a/src/common/retry.ts b/src/common/retry.ts index 5989da8..90425e1 100644 --- a/src/common/retry.ts +++ b/src/common/retry.ts @@ -1,17 +1,59 @@ import { sleep } from "../sleep"; +import { Any } from "./types"; +/** Implementation of SuppressedError + * + * Source: https://github.com/microsoft/TypeScript/blob/db3d54ffbc0a805fbdd5104c5a5137d7ca84420a/src/compiler/factory/emitHelpers.ts#L1458 + * */ +class SuppressedError extends Error { + constructor(error: any, suppressed: any, message: string) { + super(message); + this.error = error; + this.suppressed = suppressed; + } + + name = "SuppressedError"; + error: any; + suppressed: any; +} + +export class RetryError extends SuppressedError { + name = "RetryError"; +} + +/** + * Retries a function with the given delay. + * + * Suppresses errors that occur and throws a suppressed error at the end or + * returns the result when it succeeds. + * */ export const retry = (times: number, delay = 0) => { - return (fn: (...args: FArgs) => FReturn) => { - async function newFn(...args: Parameters) { + return function (fn: Fn) { + async function newFn(this: any, ...args: Parameters) { + const ctx: { error?: any; hasError: boolean } = { + error: undefined, + hasError: false, + }; + for (let attempt = 1; attempt <= times; attempt++) { try { - // @ts-ignore TS2683 const result = await fn.apply(this, args); return result; } catch (e) { + ctx.error = ctx.hasError + ? new RetryError( + e, + ctx.error, + `An error was suppressed during retry attempt ${attempt}` + ) + : e; + + ctx.hasError = true; + if (attempt >= times) { - throw e; + throw ctx.error; } + await sleep(delay); } } @@ -24,6 +66,6 @@ export const retry = (times: number, delay = 0) => { } ); - return newFn; + return newFn as Fn; }; }; diff --git a/src/common/types.ts b/src/common/types.ts index 3703994..a16857a 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -4,6 +4,9 @@ declare namespace Any { type Function = Array, O = any> = { (...args: I): O; }; + + type AsyncFunction = Array, O = any> = { + (...args: I): Promise; + }; type Array = ReadonlyArray; } - diff --git a/test/retry.test.ts b/test/retry.test.ts index 3ce6142..51129c0 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -1,11 +1,11 @@ import test from "ava"; -import { retry } from "../src"; +import { RetryError, retry } from "../src"; import { performance } from "perf_hooks"; test("maintains reference to this", async (t) => { const data = { val: 0, - retryedInc: retry(3)(function (this: any) { + retryedInc: retry(3)(async function (this: any) { return ++this.val; }), }; @@ -15,16 +15,53 @@ test("maintains reference to this", async (t) => { t.is(data.val, 1); }); +test("tracks retry errors", async (t) => { + const data = { + attempt: 0, + retryedInc: retry(3)(async function (this: any) { + throw new Error(`attempt:${++this.attempt}`); + }), + }; + + const err = await t.throwsAsync(data.retryedInc.bind(data), { + instanceOf: RetryError, + }); + + t.is(err?.message, "An error was suppressed during retry attempt 3"); + t.is(err?.error.message, "attempt:3"); + + t.is( + err?.suppressed.message, + "An error was suppressed during retry attempt 2" + ); + t.is(err?.suppressed.error.message, "attempt:2"); + + t.is(err?.suppressed.suppressed.message, "attempt:1"); +}); + +test("tracks single retry error", async (t) => { + const data = { + attempt: 0, + retryedInc: retry(1)(async function (this: any) { + throw new Error(`attempt:${++this.attempt}`); + }), + }; + + const err = await t.throwsAsync(data.retryedInc.bind(data), { + instanceOf: Error, + }); + + t.is(err?.message, "attempt:1"); +}); + test("maintains function properties", async (t) => { let val = 0; - function incBy(this: any, num: number, _dummyArg?: any) { + async function incBy(this: any, num: number, _dummyArg?: any) { val += num; return num; } - incBy.prototype.foo = "bar"; - const retryIncBy = retry(1)(incBy); await retryIncBy(10); @@ -32,13 +69,12 @@ test("maintains function properties", async (t) => { t.is(val, 10); t.is(retryIncBy.name, "incBy"); t.is(retryIncBy.length, 2); - t.is(retryIncBy.prototype.foo, "bar"); }); test("executes when no errors", async (t) => { let val = 0; - const retryedInc = retry(3)(() => { + const retryedInc = retry(3)(async () => { return ++val; }); @@ -56,7 +92,7 @@ test("retries with delay", async (t) => { const retryedInc = retry( RETRY_AMOUNT, DELAY - )(() => { + )(async () => { [currTime, prevTime] = [performance.now(), currTime]; if (prevTime) { @@ -81,7 +117,7 @@ test("retries and succeeds", async (t) => { const retryedInc = retry( RETRY_AMOUNT, DELAY - )(() => { + )(async () => { callCount += 1; // Only succeed on final retry @@ -105,7 +141,7 @@ test("retries and fails", async (t) => { const RETRY_AMOUNT = 3; - const retryedInc = retry(RETRY_AMOUNT)(() => { + const retryedInc = retry(RETRY_AMOUNT)(async () => { callCount += 1; // Only succeed on final retry