Skip to content

Commit

Permalink
chore: work on retry UT & add http retry policy
Browse files Browse the repository at this point in the history
  • Loading branch information
fraxken committed Aug 8, 2021
1 parent 44236f6 commit 456b13a
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 11 deletions.
29 changes: 21 additions & 8 deletions src/class/Operation.class.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
// Import Node.js Dependencies
// import timers from "timers/promises";
import { promisify } from "util";
import timers from "timers/promises";

// Import Internal Dependencies
import { RetryOptions } from "../retry";

// TODO: use timers/promises with Node.js v16.0.0
const setTimeoutPromise = promisify(setTimeout);

// CONSTANTS
const kDefaultOperationOptions: Partial<RetryOptions> = {
retries: 3,
minTimeout: 1_000,
maxTimeout: Infinity,
forever: false,
unref: false,
factor: 2
factor: 2,
signal: null
};

export interface OperationResult<T> {
Expand All @@ -34,6 +31,7 @@ export default class Operation<T> {
private forever: boolean;
private unref: boolean;
private factor: number;
private signal: AbortSignal;
private data: T;
private continueExecution = true;

Expand All @@ -42,7 +40,7 @@ export default class Operation<T> {
private executionTimestamp: number;
private elapsedTimeoutTime = 0;

constructor(options: RetryOptions = {}) {
constructor(options: RetryOptions) {
Object.assign(this, {}, kDefaultOperationOptions, options);
if (this.forever) {
this.retries = Infinity;
Expand All @@ -60,18 +58,33 @@ export default class Operation<T> {
return this.continueExecution;
}

ensureAbort() {
if (this.signal === null) {
return;
}

if (this.signal.aborted) {
throw new Error("Aborted");
}
}

async retry() {
this.ensureAbort();
this.attempt++;

if (this.attempt > this.retries) {
// TODO: add error causes ?

throw new Error("Exceeded the maximum number of allowed retries!");
}

const timeout = this.backoff;
const signal = this.signal ?? void 0;
this.continueExecution = true;

await setTimeoutPromise(timeout, void 0, { ref: this.unref });
await timers.setTimeout(timeout, void 0, { ref: this.unref, signal });
this.ensureAbort();

this.elapsedTimeoutTime += timeout;
}

Expand Down
12 changes: 12 additions & 0 deletions src/policies/httpcode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @see https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP
*/
const kDefaultCodes = new Set([307, 408, 429, 444, 500, 503, 504, 520, 521, 522, 523, 524]);

export function httpcode(codes: Set<number> = kDefaultCodes, useDefault = false) {
if (useDefault) {
[...kDefaultCodes].forEach((code) => codes.add(code));
}

return ({ statusCode }) => !codes.has(statusCode);
}
1 change: 1 addition & 0 deletions src/policies/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./none";
export * from "./httpcode";

export type PolicyCallback = (error?: any) => boolean;
5 changes: 3 additions & 2 deletions src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export interface RetryOptions {
unref?: boolean;
factor?: number;
forever?: boolean;
signal?: AbortSignal | null;
}

export type RetryCallback<T> = () => Promise<T>;
export type RetryCallback<T> = (() => Promise<T>) | (() => T);

export async function retry<T>(
callback: RetryCallback<T>, options: RetryOptions = {}, policy: PolicyCallback = none
Expand All @@ -28,7 +29,7 @@ export async function retry<T>(
op.success(data);
}
catch (error) {
if (error.name === "AbortError" || policy(error)) {
if (policy(error)) {
throw error;
}

Expand Down
2 changes: 1 addition & 1 deletion test/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
jest.setTimeout(15000);
jest.setTimeout(20000);
57 changes: 57 additions & 0 deletions test/retry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Import Internal Dependencies
import { retry } from "../src/index";

describe("retry (with default policy)", () => {
it("should throw an Error because the number of retries has been exceeded", async() => {
expect.assertions(1);

try {
await retry(() => {
throw new Error("exceed");
});
}
catch (error) {
expect(error.message).toStrictEqual("Exceeded the maximum number of allowed retries!");
}
});

it("should succeed after one try", async() => {
let count = 0;

const { data, metrics } = await retry<string>(() => {
count++;
if (count === 1) {
throw new Error("oops");
}

return "hello world!";
});

expect(data).toStrictEqual("hello world!");
expect(metrics.attempt).toStrictEqual(1);
expect(typeof metrics.elapsedTimeoutTime).toStrictEqual("number");
expect(typeof metrics.executionTimestamp).toStrictEqual("number");
});

it("should be stopped with Node.js AbortController", async() => {
expect.assertions(1);

let count = 0;
const controller = new AbortController();

try {
await retry(() => {
count++;
if (count <= 2) {
throw new Error("oops");
}
controller.abort();

throw new Error("oops");
}, { forever: true, signal: controller.signal });
}
catch (error) {
expect(error.message).toStrictEqual("Aborted");
}
});
});

0 comments on commit 456b13a

Please sign in to comment.