Skip to content

Commit

Permalink
feat: suppress retry errors (#24)
Browse files Browse the repository at this point in the history
* feat: suppress retry errors

* chore: subclass SuppressedError to RetryError

* chore: add missing asyncs for retry
  • Loading branch information
rexfordessilfie committed Feb 16, 2024
1 parent 43bc1dd commit fd077f7
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 16 deletions.
52 changes: 47 additions & 5 deletions src/common/retry.ts
Original file line number Diff line number Diff line change
@@ -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 <FArgs extends any[], FReturn>(fn: (...args: FArgs) => FReturn) => {
async function newFn(...args: Parameters<typeof fn>) {
return function <Fn extends Any.AsyncFunction>(fn: Fn) {
async function newFn(this: any, ...args: Parameters<Fn>) {
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);
}
}
Expand All @@ -24,6 +66,6 @@ export const retry = (times: number, delay = 0) => {
}
);

return newFn;
return newFn as Fn;
};
};
5 changes: 4 additions & 1 deletion src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ declare namespace Any {
type Function<I extends Array<unknown> = Array<any>, O = any> = {
(...args: I): O;
};

type AsyncFunction<I extends Array<unknown> = Array<any>, O = any> = {
(...args: I): Promise<O>;
};
type Array<T = unknown> = ReadonlyArray<T>;
}

56 changes: 46 additions & 10 deletions test/retry.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}),
};
Expand All @@ -15,30 +15,66 @@ 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);

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;
});

Expand All @@ -56,7 +92,7 @@ test("retries with delay", async (t) => {
const retryedInc = retry(
RETRY_AMOUNT,
DELAY
)(() => {
)(async () => {
[currTime, prevTime] = [performance.now(), currTime];

if (prevTime) {
Expand All @@ -81,7 +117,7 @@ test("retries and succeeds", async (t) => {
const retryedInc = retry(
RETRY_AMOUNT,
DELAY
)(() => {
)(async () => {
callCount += 1;

// Only succeed on final retry
Expand All @@ -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
Expand Down

0 comments on commit fd077f7

Please sign in to comment.