From 456b13a25aced3e6e607d755bff0980889028e51 Mon Sep 17 00:00:00 2001 From: Thomas GENTILHOMME Date: Sun, 8 Aug 2021 19:35:55 +0200 Subject: [PATCH] chore: work on retry UT & add http retry policy --- src/class/Operation.class.ts | 29 +++++++++++++----- src/policies/httpcode.ts | 12 ++++++++ src/policies/index.ts | 1 + src/retry.ts | 5 ++-- test/jest.setup.js | 2 +- test/retry.spec.ts | 57 ++++++++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 src/policies/httpcode.ts create mode 100644 test/retry.spec.ts diff --git a/src/class/Operation.class.ts b/src/class/Operation.class.ts index bbb5d66..b306926 100644 --- a/src/class/Operation.class.ts +++ b/src/class/Operation.class.ts @@ -1,13 +1,9 @@ // 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 = { retries: 3, @@ -15,7 +11,8 @@ const kDefaultOperationOptions: Partial = { maxTimeout: Infinity, forever: false, unref: false, - factor: 2 + factor: 2, + signal: null }; export interface OperationResult { @@ -34,6 +31,7 @@ export default class Operation { private forever: boolean; private unref: boolean; private factor: number; + private signal: AbortSignal; private data: T; private continueExecution = true; @@ -42,7 +40,7 @@ export default class Operation { private executionTimestamp: number; private elapsedTimeoutTime = 0; - constructor(options: RetryOptions = {}) { + constructor(options: RetryOptions) { Object.assign(this, {}, kDefaultOperationOptions, options); if (this.forever) { this.retries = Infinity; @@ -60,8 +58,20 @@ export default class Operation { 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 ? @@ -69,9 +79,12 @@ export default class Operation { } 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; } diff --git a/src/policies/httpcode.ts b/src/policies/httpcode.ts new file mode 100644 index 0000000..8853b1e --- /dev/null +++ b/src/policies/httpcode.ts @@ -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 = kDefaultCodes, useDefault = false) { + if (useDefault) { + [...kDefaultCodes].forEach((code) => codes.add(code)); + } + + return ({ statusCode }) => !codes.has(statusCode); +} diff --git a/src/policies/index.ts b/src/policies/index.ts index 1e9ad55..1d7bb38 100644 --- a/src/policies/index.ts +++ b/src/policies/index.ts @@ -1,3 +1,4 @@ export * from "./none"; +export * from "./httpcode"; export type PolicyCallback = (error?: any) => boolean; diff --git a/src/retry.ts b/src/retry.ts index 6db1468..fa6cd04 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -13,9 +13,10 @@ export interface RetryOptions { unref?: boolean; factor?: number; forever?: boolean; + signal?: AbortSignal | null; } -export type RetryCallback = () => Promise; +export type RetryCallback = (() => Promise) | (() => T); export async function retry( callback: RetryCallback, options: RetryOptions = {}, policy: PolicyCallback = none @@ -28,7 +29,7 @@ export async function retry( op.success(data); } catch (error) { - if (error.name === "AbortError" || policy(error)) { + if (policy(error)) { throw error; } diff --git a/test/jest.setup.js b/test/jest.setup.js index c73b59b..cb96a9f 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -1 +1 @@ -jest.setTimeout(15000); +jest.setTimeout(20000); diff --git a/test/retry.spec.ts b/test/retry.spec.ts new file mode 100644 index 0000000..a6527ff --- /dev/null +++ b/test/retry.spec.ts @@ -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(() => { + 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"); + } + }); +});