From 3c259173a5670862ba4555c5bad395df304c03e6 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sat, 23 May 2020 22:40:12 +0300 Subject: [PATCH 01/32] Update examples --- README.md | 2 +- src/retryable.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f0b635..6d3c7c6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ const content = await retryable((resolve, reject, retry) => { else // retrying after exponential backoff (see https://en.wikipedia.org/wiki/Exponential_backoff) - retry.after(2 ** retry.count * 100); + retry.after("exponential"); // same as: retry.after(2 ** retry.count * 100); }); }); ``` diff --git a/src/retryable.ts b/src/retryable.ts index 2120edc..d4a16a9 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -29,7 +29,7 @@ const RETRY_COUNT_DEFAULT = 0; * retry(); * * else - * retry.after(2 ** retry.count * 100); + * retry.after("exponential"); * }); * }); */ From d916fc997108bce0ea1af372b9434438f3bdcfec Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sat, 23 May 2020 23:09:01 +0300 Subject: [PATCH 02/32] Define and use delays --- src/delays.ts | 18 ++++++++++++++++++ src/retryable.ts | 14 +++++++++++++- src/typings/retryer.ts | 5 ++++- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/delays.ts diff --git a/src/delays.ts b/src/delays.ts new file mode 100644 index 0000000..c30ede1 --- /dev/null +++ b/src/delays.ts @@ -0,0 +1,18 @@ +/** @public */ +const delays = { + /* + Notes to self: + - beware of returning NaNs and negative numbers + - number-like strings are forbidden, as in: `delays["100"]()` // ❌ + */ + + /** @see https://en.wikipedia.org/wiki/Exponential_backoff */ + exponential(retryCount: number): number { + return 2 ** retryCount * 100; + }, +} as const; + +export type DelayNamed = keyof typeof delays; +export type Delay = number | DelayNamed; + +export default delays; diff --git a/src/retryable.ts b/src/retryable.ts index d4a16a9..3dc9e53 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -1,7 +1,10 @@ import type Action from "./typings/action"; import type Retryer from "./typings/retryer"; +import type { Delay, DelayNamed } from "./delays"; + import assertNatural from "./assert-natural.impl"; import assertNonNegative from "./assert-non-negative.impl"; +import delays from "./delays"; /** @private */ type Maybe = Value | null; @@ -86,8 +89,17 @@ export default function retryable(action: Action): Promi execute(); } - function retryAfter(msec: number): void { + function retryAfter(delay: Delay): void { + let msec: number; + + if (delay in delays) + msec = delays[delay as DelayNamed](_retryCount); + + else + msec = +delay; + assertNonNegative(msec, "retry delay"); + _retryTimeoutId = setTimeout(retry, msec); } diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index 3107f3e..01f9bc9 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -1,9 +1,12 @@ +// TODO: deprecate the whole separate '/typings' thing, +// export types from main file as much as possible + export default interface Retryer { (): void; readonly count: number; - after(msec: number): void; + after(delay: import("../delays").Delay): void; setCount(newValue: number): void; From 28e8cac2cdcbb309b1ac8643b6eaada5b1144932 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sat, 23 May 2020 23:09:10 +0300 Subject: [PATCH 03/32] Add unit tests --- test/delays.spec.ts | 25 +++++++++++++++++++++++++ test/retry-after.spec.ts | 7 +++++-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 test/delays.spec.ts diff --git a/test/delays.spec.ts b/test/delays.spec.ts new file mode 100644 index 0000000..724285b --- /dev/null +++ b/test/delays.spec.ts @@ -0,0 +1,25 @@ +import type { DelayNamed } from "../src/delays"; +import delays from "../src/delays"; + +function expectToBeDelay(key: PropertyKey): asserts key is DelayNamed { + expect(key in delays).toBe(true); +} + +describe("delays", () => { + it("defines set of functions that return delays", () => { + for (const key in delays) { + expectToBeDelay(key); + expect(typeof delays[key]).toBe("function"); + expect(delays[key]).toHaveLength(1); + } + }); + + it("defines exponential backoff as 'exponential'", () => { + expect(delays).toHaveProperty("exponential"); + + expect(delays.exponential(0)).toEqual(100); + expect(delays.exponential(1)).toEqual(200); + expect(delays.exponential(5)).toEqual(3200); + expect(delays.exponential(42)).toEqual(439804651110400); + }); +}); diff --git a/test/retry-after.spec.ts b/test/retry-after.spec.ts index 93bfbf9..49801aa 100644 --- a/test/retry-after.spec.ts +++ b/test/retry-after.spec.ts @@ -33,7 +33,10 @@ describe("retry.after()", () => { await expect(promise).rejects.toThrowError(error); }); - it("allows positive non-integer delays", () => { + test.each([ + [ "positive non-integer", 42.17 ] as const, + [ "named (exponential)", "exponential" ] as const, + ])("allows %s delays", (kind, delay) => { let retried = false; const promise = retryable((resolve, reject, retry) => { @@ -41,7 +44,7 @@ describe("retry.after()", () => { return resolve(); retried = true; - retry.after(42.17); + retry.after(delay); }); return expect(promise).resolves.toBeUndefined(); From 1f9264fc54b28e1e8eb074abb862c4eb09bef99f Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sat, 23 May 2020 23:09:43 +0300 Subject: [PATCH 04/32] Add a couple of use cases --- test/retry-after.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/retry-after.spec.ts b/test/retry-after.spec.ts index 49801aa..04a6759 100644 --- a/test/retry-after.spec.ts +++ b/test/retry-after.spec.ts @@ -34,7 +34,9 @@ describe("retry.after()", () => { }); test.each([ - [ "positive non-integer", 42.17 ] as const, + [ "zero", 0 ] as const, + [ "positive", 42 ] as const, + [ "non-integer", 42.17 ] as const, [ "named (exponential)", "exponential" ] as const, ])("allows %s delays", (kind, delay) => { let retried = false; From 21ea18ac3212708d3ab20ba196bfb5ad81da13f8 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 24 May 2020 12:00:33 +0300 Subject: [PATCH 05/32] Add unit test for 'exponential' delay --- test/retry-after.spec.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/retry-after.spec.ts b/test/retry-after.spec.ts index 04a6759..93075d3 100644 --- a/test/retry-after.spec.ts +++ b/test/retry-after.spec.ts @@ -52,3 +52,28 @@ describe("retry.after()", () => { return expect(promise).resolves.toBeUndefined(); }); }); + +describe("named delays", () => { + it("properly delays retries if 'exponential' delay is provided", async () => { + const start = time(); + const times: number[] = []; + const RETRY_LIMIT = 4; + + await retryable((resolve, reject, retry) => { + times.push(time()); + + if (retry.count >= RETRY_LIMIT) + return resolve(); + + retry.after("exponential"); + }); + + expect(times[0] - start).toBeCloseTo(time(0)); + expect(times[1] - start).toBeCloseTo(time(0) + time(100)); + expect(times[2] - start).toBeCloseTo(time(0) + time(100) + time(200)); + expect(times[3] - start).toBeCloseTo(time(0) + time(100) + time(200) + time(400)); + expect(times[4] - start).toBeCloseTo(time(0) + time(100) + time(200) + time(400) + time(800)); + + expect(times).toHaveLength(5); + }, TIMEOUT_MARGIN + 800 + 400 + 200 + 100); +}); From aa99f582f76c6e2eb326e81218ba0d3d669d371a Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 24 May 2020 12:11:25 +0300 Subject: [PATCH 06/32] Reorganize tests for retry.after() --- test/retry-after.spec.ts | 46 ++++++++++++---------------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/test/retry-after.spec.ts b/test/retry-after.spec.ts index 93075d3..20f9eae 100644 --- a/test/retry-after.spec.ts +++ b/test/retry-after.spec.ts @@ -1,26 +1,27 @@ import retryable from "../src/retryable"; -import time, { TIMEOUT_MARGIN, WAIT_TIME } from "./helpers/time"; +import time, { TIMEOUT_MARGIN } from "./helpers/time"; -describe("retry.after()", () => { - it("allows retrying after a specified delay", async () => { +describe("retry.after(msec)", () => { + test.each([ + [ "zero", 0 ], + [ "positive", TIMEOUT_MARGIN ], + [ "non-integer", TIMEOUT_MARGIN - 0.1 ], + ] as const)("allows %s delays", async (kind, delay) => { let retried = false; - const finish = time() + time(WAIT_TIME); + const finish = time() + time(delay); await retryable((resolve, reject, retry) => { if (retried) - resolve(); - - else { - retried = true; + return resolve(); - retry.after(WAIT_TIME); - } + retried = true; + retry.after(delay); }); expect(time()).toBeCloseTo(finish); expect(retried).toBe(true); - }, TIMEOUT_MARGIN + WAIT_TIME); + }); test.each([ [ "negative delays", -4, "is negative" ], @@ -32,29 +33,10 @@ describe("retry.after()", () => { await expect(promise).rejects.toThrowError(error); }); - - test.each([ - [ "zero", 0 ] as const, - [ "positive", 42 ] as const, - [ "non-integer", 42.17 ] as const, - [ "named (exponential)", "exponential" ] as const, - ])("allows %s delays", (kind, delay) => { - let retried = false; - - const promise = retryable((resolve, reject, retry) => { - if (retried) - return resolve(); - - retried = true; - retry.after(delay); - }); - - return expect(promise).resolves.toBeUndefined(); - }); }); -describe("named delays", () => { - it("properly delays retries if 'exponential' delay is provided", async () => { +describe("retry.after(strategy)", () => { + it('"exponential" triggers exponential backoff', async () => { const start = time(); const times: number[] = []; const RETRY_LIMIT = 4; From b97b2a194474c9a2fc5138a59a29212c7e3b5521 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 24 May 2020 15:11:45 +0300 Subject: [PATCH 07/32] Add suggestion in JSDoc --- src/typings/retry-count-resetter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typings/retry-count-resetter.ts b/src/typings/retry-count-resetter.ts index 82bb879..f597ea5 100644 --- a/src/typings/retry-count-resetter.ts +++ b/src/typings/retry-count-resetter.ts @@ -1,4 +1,4 @@ -/** @deprecated */ +/** @deprecated Use `Retryer["setCount"]` from _/typings/retryer.ts_ instead */ export default interface RetryCountResetter { (newValue?: number): void; } From 4eb1f8bcb938afca7f29a608b9385c52f83bcc68 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 24 May 2020 15:12:19 +0300 Subject: [PATCH 08/32] Acknowledge that there's no issue (#75) --- src/typings/retryer.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index 01f9bc9..c748285 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -1,14 +1,11 @@ -// TODO: deprecate the whole separate '/typings' thing, -// export types from main file as much as possible +import type { Delay } from "../delays"; export default interface Retryer { (): void; readonly count: number; - after(delay: import("../delays").Delay): void; - + after(delay: Delay): void; setCount(newValue: number): void; - cancel(): void; } From 57bea548ea1d2825cb1310718387a9361c320a98 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 24 May 2020 15:13:39 +0300 Subject: [PATCH 09/32] Comply with ts in definiing callable namespaces --- src/retryable.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index 3dc9e53..19369bb 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -1,5 +1,4 @@ import type Action from "./typings/action"; -import type Retryer from "./typings/retryer"; import type { Delay, DelayNamed } from "./delays"; import assertNatural from "./assert-natural.impl"; @@ -74,7 +73,7 @@ export default function retryable(action: Action): Promi reject, // explicitly relying on hoisting here // eslint-disable-next-line @typescript-eslint/no-use-before-define - retry as Retryer, + retry, // arguments below are deprecated, // left for backwards compatibility @@ -89,6 +88,9 @@ export default function retryable(action: Action): Promi execute(); } + // rough fix: TypeScript doesn't know about Object.definePropety + retry.count = _retryCount; + function retryAfter(delay: Delay): void { let msec: number; From d7223fd01ea0663db08355eb237776e7ec296a21 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 24 May 2020 15:18:48 +0300 Subject: [PATCH 10/32] Sort members of `retry` namespace more obviously --- src/retryable.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index 19369bb..3763c75 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -91,6 +91,18 @@ export default function retryable(action: Action): Promi // rough fix: TypeScript doesn't know about Object.definePropety retry.count = _retryCount; + Object.defineProperty(retry, "count", { + get(): number { + return _retryCount; + }, + + set(): never { + return reject("Cannot set readonly `count`; use `retry.setCount()` instead") as never; + }, + }); + + retry.setCount = resetRetryCount.bind(null, true); + function retryAfter(delay: Delay): void { let msec: number; @@ -110,20 +122,7 @@ export default function retryable(action: Action): Promi clearTimeout(_retryTimeoutId); } - Object.defineProperty(retry, "count", { - get(): number { - return _retryCount; - }, - - set(): never { - return reject("Cannot set readonly `count`; use `retry.setCount()` instead") as never; - }, - }); - retry.after = retryAfter; - - retry.setCount = resetRetryCount.bind(null, true); - retry.cancel = retryCancel; execute(); From 39962dfa59911170e352596a8b0b5a9c5db04053 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 12:13:49 +0300 Subject: [PATCH 11/32] Fix audit warnings --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d44ade..93a94e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4316,9 +4316,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, "lodash.memoize": { From 2b729fb7b6d052cf8e65a578511e84f5018bc33a Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 12:14:16 +0300 Subject: [PATCH 12/32] Fix typos --- src/retryable.ts | 2 +- src/wait.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index 3763c75..fe3a66c 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -88,7 +88,7 @@ export default function retryable(action: Action): Promi execute(); } - // rough fix: TypeScript doesn't know about Object.definePropety + // rough fix: TypeScript doesn't know about Object.defineProperty retry.count = _retryCount; Object.defineProperty(retry, "count", { diff --git a/src/wait.ts b/src/wait.ts index 7f92b4f..7d68afa 100644 --- a/src/wait.ts +++ b/src/wait.ts @@ -1,5 +1,5 @@ /** - * Сreate a promise that resolves after a given number of milliseconds + * Create a promise that resolves after a given number of milliseconds * @param {number} msec */ export default function wait(msec: number): Promise { From abe5bc30977150548b22e2cb1d6fbff55f3a03fe Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 12:14:59 +0300 Subject: [PATCH 13/32] Allow `undefined` in Maybe generic --- src/retryable.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index fe3a66c..ef39971 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -6,7 +6,7 @@ import assertNonNegative from "./assert-non-negative.impl"; import delays from "./delays"; /** @private */ -type Maybe = Value | null; +type Maybe = Value | null | undefined; /** @private */ const RETRY_COUNT_DEFAULT = 0; @@ -40,10 +40,10 @@ export default function retryable(action: Action): Promi let _retryCount: number = RETRY_COUNT_DEFAULT; /** @private */ - let _nextRetryCount: Maybe = null; + let _nextRetryCount: Maybe; /** @private */ - let _retryTimeoutId: Maybe = null; + let _retryTimeoutId: Maybe; /** @private */ function updateRetryCount(): void { From 8fc134dd6b0424e5b72c0333ca570d2b7af44880 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 12:24:41 +0300 Subject: [PATCH 14/32] Write condition explicitly --- src/retryable.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index ef39971..5c15b6e 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -56,8 +56,8 @@ export default function retryable(action: Action): Promi } function resetRetryCount(argumentRequired: boolean, retryCountExplicit = RETRY_COUNT_DEFAULT): void { - if (!argumentRequired) - retryCountExplicit = retryCountExplicit ?? RETRY_COUNT_DEFAULT; + if (!argumentRequired && retryCountExplicit == null) + retryCountExplicit = RETRY_COUNT_DEFAULT; if (retryCountExplicit !== RETRY_COUNT_DEFAULT) assertNatural(retryCountExplicit, "new value of retryCount"); From 26d9e17e16eca4f34947df227e76ecf366f098ad Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 12:29:53 +0300 Subject: [PATCH 15/32] Check the type instead of enforcing it --- src/delays.ts | 5 +++++ src/retryable.ts | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/delays.ts b/src/delays.ts index c30ede1..7a64dc1 100644 --- a/src/delays.ts +++ b/src/delays.ts @@ -4,6 +4,7 @@ const delays = { Notes to self: - beware of returning NaNs and negative numbers - number-like strings are forbidden, as in: `delays["100"]()` // ❌ + - functions in this namespace must have similar argument structure */ /** @see https://en.wikipedia.org/wiki/Exponential_backoff */ @@ -15,4 +16,8 @@ const delays = { export type DelayNamed = keyof typeof delays; export type Delay = number | DelayNamed; +export function isNamed(delay: Delay): delay is DelayNamed { + return delay in delays; +} + export default delays; diff --git a/src/retryable.ts b/src/retryable.ts index 5c15b6e..0ec5b93 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -1,9 +1,9 @@ import type Action from "./typings/action"; -import type { Delay, DelayNamed } from "./delays"; +import type { Delay } from "./delays"; import assertNatural from "./assert-natural.impl"; import assertNonNegative from "./assert-non-negative.impl"; -import delays from "./delays"; +import delays, { isNamed } from "./delays"; /** @private */ type Maybe = Value | null | undefined; @@ -106,8 +106,8 @@ export default function retryable(action: Action): Promi function retryAfter(delay: Delay): void { let msec: number; - if (delay in delays) - msec = delays[delay as DelayNamed](_retryCount); + if (isNamed(delay)) + msec = delays[delay](_retryCount); else msec = +delay; From a196b4907243ca208460b58820223a6a7a0c2462 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 12:49:51 +0300 Subject: [PATCH 16/32] Remove duplication (#75) --- src/retryable.ts | 77 +++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index 0ec5b93..d72557e 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -1,4 +1,5 @@ import type Action from "./typings/action"; +import type Retryer from "./typings/retryer"; import type { Delay } from "./delays"; import assertNatural from "./assert-natural.impl"; @@ -66,30 +67,36 @@ export default function retryable(action: Action): Promi } return new Promise((resolve, reject) => { - /** @private */ - function execute(): void { - action( - resolve, - reject, - // explicitly relying on hoisting here - // eslint-disable-next-line @typescript-eslint/no-use-before-define - retry, + const retry: Retryer = Object.assign(() => { + updateRetryCount(); - // arguments below are deprecated, - // left for backwards compatibility + // explicitly relying on hoisting here + // eslint-disable-next-line @typescript-eslint/no-use-before-define + execute(); + }, { + count: _retryCount, // temporary - _retryCount, - resetRetryCount.bind(null, false), - ); - } + setCount: resetRetryCount.bind(null, true), - function retry(): void { - updateRetryCount(); - execute(); - } + after(delay: Delay): void { + let msec: number; + + if (isNamed(delay)) + msec = delays[delay](_retryCount); + + else + msec = +delay; - // rough fix: TypeScript doesn't know about Object.defineProperty - retry.count = _retryCount; + assertNonNegative(msec, "retry delay"); + + _retryTimeoutId = setTimeout(retry, msec); + }, + + cancel(): void { + if (_retryTimeoutId != null) + clearTimeout(_retryTimeoutId); + }, + }); Object.defineProperty(retry, "count", { get(): number { @@ -101,30 +108,20 @@ export default function retryable(action: Action): Promi }, }); - retry.setCount = resetRetryCount.bind(null, true); - - function retryAfter(delay: Delay): void { - let msec: number; - - if (isNamed(delay)) - msec = delays[delay](_retryCount); - - else - msec = +delay; - - assertNonNegative(msec, "retry delay"); + function execute(): void { + action( + resolve, + reject, + retry, - _retryTimeoutId = setTimeout(retry, msec); - } + // arguments below are deprecated, + // left for backwards compatibility - function retryCancel(): void { - if (_retryTimeoutId != null) - clearTimeout(_retryTimeoutId); + _retryCount, + resetRetryCount.bind(null, false), + ); } - retry.after = retryAfter; - retry.cancel = retryCancel; - execute(); }); } From b3606b02969d3124a878dcaadb7e0e87771b4c79 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 13:09:04 +0300 Subject: [PATCH 17/32] Add JSDoc entries for Retryer --- src/typings/retryer.ts | 51 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index c748285..5defce1 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -3,9 +3,58 @@ import type { Delay } from "../delays"; export default interface Retryer { (): void; + /** + * Readonly number of retries that occurred so far. + * Always 1 less than the number of total attempts. + * @example + * console.log(retry.count); + * // [iteration #1] logs 0 + * // [iteration #2] logs 1 + * // [iteration #3] logs 2 + * // ... + * + * retry(); + */ readonly count: number; - after(delay: Delay): void; + /** + * Set value of `retry.count` for the next iteration + * @example + * retry.setCount(42); + * + * console.log(retry.count); + * // [iteration #1] logs 0 + * // [iteration #2] logs 42 + * // [iteration #3] logs 42 + * // ... + * + * retry(); + */ setCount(newValue: number): void; + + /** + * Delays retry(s) by a given strategy or timer + * @param delay The delay + * @example + * retry.after(300); + * // retry after 300 milliseconds + * + * retry.after(1000); + * // retry after 1 second + * + * retry.after("exponential"); + * // use "Exponential backoff" retry strategy + * + * retry.after(0); + * // retry asynchronously + */ + after(delay: Delay): void; + + /** + * Cancel the delayed retry + * @example + * retry.after(1000); // doesn't work + * retry.cancel(); + */ cancel(): void; } From c081c848a0a403bba5ae9d55d93ba2800859cfac Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 13:34:05 +0300 Subject: [PATCH 18/32] Use "attempt" term --- src/typings/retryer.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index 5defce1..a831bf9 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -8,9 +8,9 @@ export default interface Retryer { * Always 1 less than the number of total attempts. * @example * console.log(retry.count); - * // [iteration #1] logs 0 - * // [iteration #2] logs 1 - * // [iteration #3] logs 2 + * // [attempt #1] logs 0 + * // [attempt #2] logs 1 + * // [attempt #3] logs 2 * // ... * * retry(); @@ -18,14 +18,14 @@ export default interface Retryer { readonly count: number; /** - * Set value of `retry.count` for the next iteration + * Set value of `retry.count` for the next attempt * @example * retry.setCount(42); * * console.log(retry.count); - * // [iteration #1] logs 0 - * // [iteration #2] logs 42 - * // [iteration #3] logs 42 + * // [attempt #1] logs 0 + * // [attempt #2] logs 42 + * // [attempt #3] logs 42 * // ... * * retry(); From 86e9b6fb667b91d10b76af7307b57d4d9a122e39 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 16:58:26 +0300 Subject: [PATCH 19/32] Rephrase JSDoc --- src/typings/retryer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index a831bf9..bfbfc3d 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -53,7 +53,7 @@ export default interface Retryer { /** * Cancel the delayed retry * @example - * retry.after(1000); // doesn't work + * retry.after(1000); // won't retry * retry.cancel(); */ cancel(): void; From 599b61a0bb0e2a1f292da274aa4dd02251f4b617 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 17:14:40 +0300 Subject: [PATCH 20/32] Delegate type inferrence to TypeScript --- src/retryable.ts | 8 ++++---- src/typings/retryer.ts | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index d72557e..2c057b1 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -1,6 +1,6 @@ import type Action from "./typings/action"; import type Retryer from "./typings/retryer"; -import type { Delay } from "./delays"; +import type { RetryerProps } from "./typings/retryer"; import assertNatural from "./assert-natural.impl"; import assertNonNegative from "./assert-non-negative.impl"; @@ -78,7 +78,7 @@ export default function retryable(action: Action): Promi setCount: resetRetryCount.bind(null, true), - after(delay: Delay): void { + after(delay) { let msec: number; if (isNamed(delay)) @@ -92,11 +92,11 @@ export default function retryable(action: Action): Promi _retryTimeoutId = setTimeout(retry, msec); }, - cancel(): void { + cancel() { if (_retryTimeoutId != null) clearTimeout(_retryTimeoutId); }, - }); + } as RetryerProps); Object.defineProperty(retry, "count", { get(): number { diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index bfbfc3d..d97dbe1 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -1,8 +1,6 @@ import type { Delay } from "../delays"; -export default interface Retryer { - (): void; - +export interface RetryerProps { /** * Readonly number of retries that occurred so far. * Always 1 less than the number of total attempts. @@ -58,3 +56,7 @@ export default interface Retryer { */ cancel(): void; } + +export default interface Retryer extends RetryerProps { + (): void; +} From 59380e68df1cf2944f899ed2435473a5fa164c76 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 17:17:51 +0300 Subject: [PATCH 21/32] Implement retry.setMaxCount() --- src/retryable.ts | 53 ++++++++++++++++++++++++++++++++++++------ src/typings/retryer.ts | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/retryable.ts b/src/retryable.ts index 2c057b1..d669d8d 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -37,16 +37,18 @@ const RETRY_COUNT_DEFAULT = 0; * }); */ export default function retryable(action: Action): Promise { - /** @private */ - let _retryCount: number = RETRY_COUNT_DEFAULT; + let _retryTimeoutId: Maybe; - /** @private */ + let _retryCount: number = RETRY_COUNT_DEFAULT; let _nextRetryCount: Maybe; - /** @private */ - let _retryTimeoutId: Maybe; + let _maxRetryCount = Infinity; + let _maxRetryCountSet = false; // set by user that is + let _onMaxRetryCountExceeded: Maybe<() => unknown>; + + let _resolved = false; + let _rejected = false; - /** @private */ function updateRetryCount(): void { if (_nextRetryCount != null) { _retryCount = _nextRetryCount; @@ -66,10 +68,30 @@ export default function retryable(action: Action): Promi _nextRetryCount = retryCountExplicit; } - return new Promise((resolve, reject) => { + return new Promise((res, rej) => { + const resolve: typeof res = (...args) => { + _resolved = true; + res(...args); + }; + + const reject: typeof rej = (...args) => { + _rejected = true; + rej(...args); + }; + const retry: Retryer = Object.assign(() => { updateRetryCount(); + if (_retryCount > _maxRetryCount) + if (_onMaxRetryCountExceeded) + _onMaxRetryCountExceeded(); + + else + return reject(`Retry limit exceeded after ${ _maxRetryCount } retries (${ _maxRetryCount + 1 } attempts total)`); + + if (_resolved || _rejected) + return; + // explicitly relying on hoisting here // eslint-disable-next-line @typescript-eslint/no-use-before-define execute(); @@ -78,6 +100,23 @@ export default function retryable(action: Action): Promi setCount: resetRetryCount.bind(null, true), + setMaxCount(value, onExceeded?) { + if (_maxRetryCountSet) + return; // ignore subsequent calls + + _maxRetryCount = value; + _maxRetryCountSet = true; + + if (onExceeded == null) + return; + + if (onExceeded === "resolve") + _onMaxRetryCountExceeded = resolve; + + else if (onExceeded !== "reject") + _onMaxRetryCountExceeded = onExceeded; + }, + after(delay) { let msec: number; diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index d97dbe1..0d883b0 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -1,5 +1,7 @@ import type { Delay } from "../delays"; +export type OnMaxRetryCountExceeded = "resolve" | "reject" | (() => unknown); + export interface RetryerProps { /** * Readonly number of retries that occurred so far. @@ -30,6 +32,46 @@ export interface RetryerProps { */ setCount(newValue: number): void; + /** + * Set upper limit for the value of `retry.count` + * @param value Value of `retry.count` + * @param onExceeded (defaults to `"reject"`) Limit exceed action + * @example + * retry.setMaxCount(0); + * retry.setMaxCount(0, "reject"); + * // [attempt #1] reject with "Max retry count exceeded …" + * + * retry.setMaxCount(1); + * // [attempt #1] retry + * // [attempt #2] reject with "Max retry count exceeded …" + * + * retry.setMaxCount(1, "resolve"); + * retry.setMaxCount(1, resolve); + * // [attempt #1] retry + * // [attempt #2] resolve to `undefined` + * + * retry.setMaxCount(1, () => resolve(42)); + * // [attempt #1] retry + * // [attempt #2] resolve to `42` + * + * retry.setMaxCount(1, () => retry.setCount(0)); + * // [attempt #1] retry + * // [attempt #2] retry (update count) + * // [attempt #3] retry + * // [attempt #4] retry (update count) + * // [attempt #5] retry + * // ... + * + * retry.setMaxCount(1, () => retry.setCount(1)); + * // [attempt #1] retry + * // [attempt #2] retry (update count) + * // [attempt #3] retry (update count) + * // [attempt #4] retry (update count) + * // [attempt #5] retry (update count) + * // ... + */ + setMaxCount(value: number, onExceeded?: OnMaxRetryCountExceeded): void; + /** * Delays retry(s) by a given strategy or timer * @param delay The delay From bd28c44dc233fe582dcce07d3bf6d3eef43ae2a3 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 17:26:45 +0300 Subject: [PATCH 22/32] Improve validation error message --- src/retryable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/retryable.ts b/src/retryable.ts index d669d8d..6468dee 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -63,7 +63,7 @@ export default function retryable(action: Action): Promi retryCountExplicit = RETRY_COUNT_DEFAULT; if (retryCountExplicit !== RETRY_COUNT_DEFAULT) - assertNatural(retryCountExplicit, "new value of retryCount"); + assertNatural(retryCountExplicit, "new value of retry.count"); _nextRetryCount = retryCountExplicit; } From a38c69f7590c5d97f96c61007901410f7663c42c Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 17:38:33 +0300 Subject: [PATCH 23/32] Fix typo --- src/retryable.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/retryable.ts b/src/retryable.ts index 6468dee..bc9267f 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -104,6 +104,8 @@ export default function retryable(action: Action): Promi if (_maxRetryCountSet) return; // ignore subsequent calls + assertNatural(value, "max value of retry.count"); + _maxRetryCount = value; _maxRetryCountSet = true; From 803499615db11c9d73cc43f8e94ff766046bd28d Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 17:55:52 +0300 Subject: [PATCH 24/32] Start test descriptions with "should" --- test/assigned-exports.spec.ts | 8 ++++---- test/commonjs-imports.spec.js | 12 ++++++------ test/delays.spec.ts | 28 +++++++++++++--------------- test/mixed-import.spec.ts | 10 +++++----- test/rejecter.spec.ts | 2 +- test/resolver.spec.ts | 2 +- test/retry-after.spec.ts | 8 ++++---- test/retry-cancel.spec.ts | 22 +++++++++++----------- test/retry-count.spec.ts | 6 +++--- test/retry-set-count.spec.ts | 6 +++--- test/retryer.spec.ts | 2 +- test/wait.spec.ts | 4 ++-- 12 files changed, 54 insertions(+), 56 deletions(-) diff --git a/test/assigned-exports.spec.ts b/test/assigned-exports.spec.ts index 88f617d..872362a 100644 --- a/test/assigned-exports.spec.ts +++ b/test/assigned-exports.spec.ts @@ -1,14 +1,14 @@ import retryable = require("../src"); -import TypeOf from "./helpers/typeof.type"; +import type TypeOf from "./helpers/typeof.type"; describe('import retryable = require("@parzh/retryable")', () => { - it("imports the whole module", () => { + it("should import the whole module", () => { expect(retryable).toHaveProperty("retryable"); expect(retryable).toHaveProperty("wait"); expect(retryable).toHaveProperty("default"); }); - it("is callable (delegates to the `retryable` function)", async () => { + it("should be callable (delegate to the `retryable` function)", async () => { expect(typeof retryable).toBe("function"); const value = await retryable<42>((resolve) => { @@ -18,5 +18,5 @@ describe('import retryable = require("@parzh/retryable")', () => { expect(value).toBe(42); }); - it.todo("and looks ugly, but that works without tests"); + it.todo("looks ugly, but that works without tests"); }); diff --git a/test/commonjs-imports.spec.js b/test/commonjs-imports.spec.js index e1b3708..6a3b5ea 100644 --- a/test/commonjs-imports.spec.js +++ b/test/commonjs-imports.spec.js @@ -2,13 +2,13 @@ const module_ = require("../src"); // eslint-disable-line @typescript-eslint/no-var-requires describe('const retryable = require("@parzh/retryable")', () => { - it("imports the whole module", () => { + it("should import the whole module", () => { expect(module_).toHaveProperty("retryable"); expect(module_).toHaveProperty("wait"); expect(module_).toHaveProperty("default"); }); - it("is callable (delegates to the `retryable` function)", async () => { + it("should be callable (delegate to the `retryable` function)", async () => { expect(typeof module_).toBe("function"); const value = await module_((resolve) => { @@ -20,11 +20,11 @@ describe('const retryable = require("@parzh/retryable")', () => { }); describe('const retryable = require("@parzh/retryable").default', () => { - it("imports the `retryable` function itself", () => { + it("should import the `retryable` function itself", () => { expect(typeof module_.default).toBe("function"); }); - it("without importing the whole module", () => { + it("should not import the whole module", () => { expect(module_.default).not.toHaveProperty("retryable"); expect(module_.default).not.toHaveProperty("wait"); expect(module_.default).not.toHaveProperty("default"); @@ -32,13 +32,13 @@ describe('const retryable = require("@parzh/retryable").default', () => { }); describe('const { retryable } = require("@parzh/retryable")', () => { - it("imports the `retryable` function itself", () => { + it("should import the `retryable` function itself", () => { expect(typeof module_.retryable).toBe("function"); }); }); describe('const { wait } = require("@parzh/retryable")', () => { - it("imports the `wait` function itself", () => { + it("should import the `wait` function itself", () => { expect(typeof module_.wait).toBe("function"); }); }); diff --git a/test/delays.spec.ts b/test/delays.spec.ts index 724285b..bd2da25 100644 --- a/test/delays.spec.ts +++ b/test/delays.spec.ts @@ -5,21 +5,19 @@ function expectToBeDelay(key: PropertyKey): asserts key is DelayNamed { expect(key in delays).toBe(true); } -describe("delays", () => { - it("defines set of functions that return delays", () => { - for (const key in delays) { - expectToBeDelay(key); - expect(typeof delays[key]).toBe("function"); - expect(delays[key]).toHaveLength(1); - } - }); +it("should define set of functions that return delays", () => { + for (const key in delays) { + expectToBeDelay(key); + expect(typeof delays[key]).toBe("function"); + expect(delays[key]).toHaveLength(1); + } +}); - it("defines exponential backoff as 'exponential'", () => { - expect(delays).toHaveProperty("exponential"); +it("should define exponential backoff as 'exponential'", () => { + expect(delays).toHaveProperty("exponential"); - expect(delays.exponential(0)).toEqual(100); - expect(delays.exponential(1)).toEqual(200); - expect(delays.exponential(5)).toEqual(3200); - expect(delays.exponential(42)).toEqual(439804651110400); - }); + expect(delays.exponential(0)).toEqual(100); + expect(delays.exponential(1)).toEqual(200); + expect(delays.exponential(5)).toEqual(3200); + expect(delays.exponential(42)).toEqual(439804651110400); }); diff --git a/test/mixed-import.spec.ts b/test/mixed-import.spec.ts index 1a07ef5..d931d3d 100644 --- a/test/mixed-import.spec.ts +++ b/test/mixed-import.spec.ts @@ -2,7 +2,7 @@ import module_, { retryable, wait } from "../src"; import TypeOf from "./helpers/typeof.type"; describe('import retryable from "@parzh/retryable"', () => { - it("imports the `retryable` function itself", async () => { + it("should import the `retryable` function itself", async () => { expect(typeof module_).toBe("function"); const value = await module_<42>((resolve) => { @@ -12,7 +12,7 @@ describe('import retryable from "@parzh/retryable"', () => { expect(value).toBe(42); }); - it("without importing the whole module", () => { + it("should not import the whole module", () => { expect(module_).not.toHaveProperty("retryable"); expect(module_).not.toHaveProperty("wait"); expect(module_).not.toHaveProperty("default"); @@ -20,7 +20,7 @@ describe('import retryable from "@parzh/retryable"', () => { }); describe('import { retryable } from "@parzh/retryable"', () => { - it("imports the `retryable` function itself", async () => { + it("should import the `retryable` function itself", async () => { expect(typeof retryable).toBe("function"); const value = await retryable<42>((resolve) => { @@ -30,7 +30,7 @@ describe('import { retryable } from "@parzh/retryable"', () => { expect(value).toBe(42); }); - it("without importing the whole module", () => { + it("should not import the whole module", () => { expect(retryable).not.toHaveProperty("retryable"); expect(retryable).not.toHaveProperty("wait"); expect(retryable).not.toHaveProperty("default"); @@ -38,7 +38,7 @@ describe('import { retryable } from "@parzh/retryable"', () => { }); describe('import { wait } from "@parzh/retryable"', () => { - it("imports the `wait` function itself", () => { + it("should import the `wait` function itself", () => { expect(typeof wait).toBe("function"); }); }); diff --git a/test/rejecter.spec.ts b/test/rejecter.spec.ts index 17faeda..0430e52 100644 --- a/test/rejecter.spec.ts +++ b/test/rejecter.spec.ts @@ -1,7 +1,7 @@ import retryable from "../src/retryable"; describe("reject()", () => { - it("works like Promise.reject()", async () => { + it("should work like Promise.reject()", async () => { const promise = retryable((resolve, reject) => { reject("Unexpected error"); }); diff --git a/test/resolver.spec.ts b/test/resolver.spec.ts index 5f1ea16..5f9db49 100644 --- a/test/resolver.spec.ts +++ b/test/resolver.spec.ts @@ -1,7 +1,7 @@ import retryable from "../src/retryable"; describe("resolve()", () => { - it("works like Promise.resolve()", async () => { + it("should work like Promise.resolve()", async () => { const value = await retryable((resolve) => { resolve(42); }); diff --git a/test/retry-after.spec.ts b/test/retry-after.spec.ts index 20f9eae..74339b9 100644 --- a/test/retry-after.spec.ts +++ b/test/retry-after.spec.ts @@ -6,7 +6,7 @@ describe("retry.after(msec)", () => { [ "zero", 0 ], [ "positive", TIMEOUT_MARGIN ], [ "non-integer", TIMEOUT_MARGIN - 0.1 ], - ] as const)("allows %s delays", async (kind, delay) => { + ] as const)("should allow %s delays", async (kind, delay) => { let retried = false; const finish = time() + time(delay); @@ -26,7 +26,7 @@ describe("retry.after(msec)", () => { test.each([ [ "negative delays", -4, "is negative" ], [ "NaNs", NaN, "is not a number" ], - ])("forbids %s", async (name, delay, error) => { + ])("should forbid %s", async (name, delay, error) => { const promise = retryable((resolve, reject, retry) => { retry.after(delay); }); @@ -35,8 +35,8 @@ describe("retry.after(msec)", () => { }); }); -describe("retry.after(strategy)", () => { - it('"exponential" triggers exponential backoff', async () => { +describe('retry.after("exponential")', () => { + it("should trigger exponential backoff", async () => { const start = time(); const times: number[] = []; const RETRY_LIMIT = 4; diff --git a/test/retry-cancel.spec.ts b/test/retry-cancel.spec.ts index d724233..f5aba8f 100644 --- a/test/retry-cancel.spec.ts +++ b/test/retry-cancel.spec.ts @@ -2,19 +2,19 @@ import retryable from "../src/retryable"; import { TIMEOUT_MARGIN, WAIT_TIME, SECOND } from "./helpers/time"; describe("retry.cancel()", () => { - it("allows to cancel planned retryable action", async () => { - let value = 0; + it("should allow cancelling delayed retry", async () => { + let value = 0; - await retryable((resolve, reject, retry) => { - if (value === 0) - setTimeout(resolve, SECOND); + await retryable((resolve, reject, retry) => { + if (value === 0) + setTimeout(resolve, SECOND); - value++ + value++ - retry.after(WAIT_TIME); - retry.cancel(); - }); + retry.after(WAIT_TIME); + retry.cancel(); + }); - expect(value).toEqual(1); - }, TIMEOUT_MARGIN + SECOND) + expect(value).toEqual(1); + }, TIMEOUT_MARGIN + SECOND) }); diff --git a/test/retry-count.spec.ts b/test/retry-count.spec.ts index 35a2e53..674bf3a 100644 --- a/test/retry-count.spec.ts +++ b/test/retry-count.spec.ts @@ -1,7 +1,7 @@ import retryable from "../src/retryable"; describe("retry.count", () => { - it("provides current number of retries so far", async () => { + it("should provide current number of retries so far", async () => { const TARGET_VALUE = 10; let value = 0; @@ -21,7 +21,7 @@ describe("retry.count", () => { expect(value).toEqual(TARGET_VALUE); }); - it("is a readonly value", () => { + it("should be readonly", () => { const promise = retryable((resolve, reject, retry) => { // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore @@ -33,5 +33,5 @@ describe("retry.count", () => { }); describe("retryCount", () => { - it.todo("is deprecated in favor of `retry.count`"); + it.todo("deprecated in favor of `retry.count`"); }); diff --git a/test/retry-set-count.spec.ts b/test/retry-set-count.spec.ts index 9e937d4..6619d9c 100644 --- a/test/retry-set-count.spec.ts +++ b/test/retry-set-count.spec.ts @@ -4,7 +4,7 @@ import retryable from "../src/retryable"; const RETRIES_BEFORE_RESET = 5; describe("retry.setCount()", () => { - it("allows explicitly seting the value of retry count", async () => { + it("should allow explicitly setting the value of retry count", async () => { let didReset = false; const lastRetryCount = await retryable((resolve, reject, retry) => { @@ -29,7 +29,7 @@ describe("retry.setCount()", () => { [ "negative numbers", -4, "is negative" ], [ "non-integers", 42.17, "not an integer" ], [ "NaNs", NaN, "is not a number" ], - ])("forbids %s", async (name, count, error) => { + ])("should forbid %s", async (name, count, error) => { const promise = retryable((resolve, reject, retry) => { retry.setCount(count); }); @@ -39,5 +39,5 @@ describe("retry.setCount()", () => { }); describe("resetRetryCount()", () => { - it.todo("is deprecated in favor of `retry.setCount(0)`"); + it.todo("deprecated in favor of `retry.setCount(0)`"); }); diff --git a/test/retryer.spec.ts b/test/retryer.spec.ts index 87ca182..bf50bb3 100644 --- a/test/retryer.spec.ts +++ b/test/retryer.spec.ts @@ -1,7 +1,7 @@ import retryable from "../src/retryable"; describe("retry()", () => { - it("allows retrying the action", async () => { + it("should allow retrying the action", async () => { let status: "initial" | "retried" | "resolved" | "rejected" = "initial"; await retryable((resolve, reject, retry) => { diff --git a/test/wait.spec.ts b/test/wait.spec.ts index de03d94..e441acb 100644 --- a/test/wait.spec.ts +++ b/test/wait.spec.ts @@ -1,7 +1,7 @@ import wait from "../src/wait"; import time, { WAIT_TIME, TIMEOUT_MARGIN } from "./helpers/time"; -it("resolves after the specified amount of time", async () => { +it("should resolve after the specified amount of time", async () => { const finish = time() + time(WAIT_TIME); await wait(WAIT_TIME); @@ -9,7 +9,7 @@ it("resolves after the specified amount of time", async () => { expect(time()).toBeCloseTo(finish); }, TIMEOUT_MARGIN + WAIT_TIME); -it("resolves immediately (asynchronously) if no time is provided", async () => { +it("should resolve immediately (asynchronously) if no time is provided", async () => { const start = time(); // eslint-disable-next-line @typescript-eslint/ban-ts-ignore From 7b90fb9a099246b24a641810fc51557ceb3e92d3 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 18:52:25 +0300 Subject: [PATCH 25/32] Automatically reset mocks in tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db922e3..d539aec 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "transform": { "\\.ts$": "ts-jest" }, - "restoreMocks": true, + "resetMocks": true, "errorOnDeprecated": true, "cacheDirectory": "/.cache/jest" }, From 69511b7cdc44a073f4026d0f360f0f6657c3b08f Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 18:52:37 +0300 Subject: [PATCH 26/32] Fix error message --- src/typings/retryer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index 0d883b0..a5658a1 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -39,11 +39,11 @@ export interface RetryerProps { * @example * retry.setMaxCount(0); * retry.setMaxCount(0, "reject"); - * // [attempt #1] reject with "Max retry count exceeded …" + * // [attempt #1] reject with "Retry limit exceeded …" * * retry.setMaxCount(1); * // [attempt #1] retry - * // [attempt #2] reject with "Max retry count exceeded …" + * // [attempt #2] reject with "Retry limit exceeded …" * * retry.setMaxCount(1, "resolve"); * retry.setMaxCount(1, resolve); From ce7cfdd47f852e0723657511800856dfe3a0c254 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 18:53:05 +0300 Subject: [PATCH 27/32] Simplify test logic --- test/retry-cancel.spec.ts | 12 +++++------- test/retry-count.spec.ts | 13 +++++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/test/retry-cancel.spec.ts b/test/retry-cancel.spec.ts index f5aba8f..04a3c25 100644 --- a/test/retry-cancel.spec.ts +++ b/test/retry-cancel.spec.ts @@ -1,20 +1,18 @@ import retryable from "../src/retryable"; import { TIMEOUT_MARGIN, WAIT_TIME, SECOND } from "./helpers/time"; +const action = jest.fn(); + describe("retry.cancel()", () => { it("should allow cancelling delayed retry", async () => { - let value = 0; - await retryable((resolve, reject, retry) => { - if (value === 0) - setTimeout(resolve, SECOND); - - value++ + action(); retry.after(WAIT_TIME); retry.cancel(); + setTimeout(resolve, SECOND); }); - expect(value).toEqual(1); + expect(action).toHaveBeenCalledTimes(1); }, TIMEOUT_MARGIN + SECOND) }); diff --git a/test/retry-count.spec.ts b/test/retry-count.spec.ts index 674bf3a..02c16bb 100644 --- a/test/retry-count.spec.ts +++ b/test/retry-count.spec.ts @@ -2,23 +2,20 @@ import retryable from "../src/retryable"; describe("retry.count", () => { it("should provide current number of retries so far", async () => { - const TARGET_VALUE = 10; - - let value = 0; + const ATTEMPTS = 10; + const counts: number[] = []; await retryable((resolve, reject, retry) => { - expect(retry.count).toEqual(value); - - value++; + counts.push(retry.count); - if (value < TARGET_VALUE) + if (counts.length < ATTEMPTS) retry(); else resolve(); }); - expect(value).toEqual(TARGET_VALUE); + expect(counts).toStrictEqual([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]); }); it("should be readonly", () => { From 44528dc00ab8af500044b855172be1013978eb44 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 21:31:34 +0300 Subject: [PATCH 28/32] Add tests for .setMaxCount --- test/retry-set-max-count.spec.ts | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 test/retry-set-max-count.spec.ts diff --git a/test/retry-set-max-count.spec.ts b/test/retry-set-max-count.spec.ts new file mode 100644 index 0000000..4b30f2a --- /dev/null +++ b/test/retry-set-max-count.spec.ts @@ -0,0 +1,58 @@ +import retryable from "../src/retryable"; + +const action = jest.fn(); + +describe("retry.setMaxCount()", () => { + test.each([ + [ "negative numbers", -4, "is negative" ], + [ "non-integers", 42.17, "not an integer" ], + [ "NaNs", NaN, "is not a number" ], + ])("should forbid %s", async (name, value, error) => { + const promise = retryable((resolve, reject, retry) => { + retry.setMaxCount(value); + }); + + await expect(promise).rejects.toThrowError(error); + }); + + test.each([ + [ "implicitly", [ ] ], + [ "explicitly", [ "reject" ] ], + ] as const)("should reject (%s), if retry count exceeded", async (how, [ onExceeded ]) => { + const promise = retryable((resolve, reject, retry) => { + retry.setMaxCount(5, onExceeded); + action(); + retry(); + }); + + await expect(promise).rejects.toEqual( + "Retry limit exceeded after 5 retries (6 attempts total)", + ); + + expect(action).toHaveBeenCalledTimes(6); + }); + + it("should allow resolving to `undefined`", async () => { + const promise = retryable((resolve, reject, retry) => { + retry.setMaxCount(5, "resolve"); + action(); + retry(); + }); + + await expect(promise).resolves.toBeUndefined(); + + expect(action).toHaveBeenCalledTimes(6); + }); + + it("should allow providing custom action", async () => { + const promise = retryable((resolve, reject, retry) => { + retry.setMaxCount(5, () => resolve(42)); + action(); + retry(); + }); + + await expect(promise).resolves.toEqual(42); + + expect(action).toHaveBeenCalledTimes(6); + }); +}); From 0974467998a8e9b2df76c4dbf9a1218e261a3e75 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 22:20:34 +0300 Subject: [PATCH 29/32] Remove trivial type cast --- src/retryable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/retryable.ts b/src/retryable.ts index bc9267f..d05898b 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -39,7 +39,7 @@ const RETRY_COUNT_DEFAULT = 0; export default function retryable(action: Action): Promise { let _retryTimeoutId: Maybe; - let _retryCount: number = RETRY_COUNT_DEFAULT; + let _retryCount = RETRY_COUNT_DEFAULT; let _nextRetryCount: Maybe; let _maxRetryCount = Infinity; From 529406acf044d020314c8aa072afe2e79c6a3b9a Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 22:33:24 +0300 Subject: [PATCH 30/32] Properly check non-void value --- src/retryable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/retryable.ts b/src/retryable.ts index d05898b..1bf1276 100644 --- a/src/retryable.ts +++ b/src/retryable.ts @@ -83,7 +83,7 @@ export default function retryable(action: Action): Promi updateRetryCount(); if (_retryCount > _maxRetryCount) - if (_onMaxRetryCountExceeded) + if (_onMaxRetryCountExceeded != null) _onMaxRetryCountExceeded(); else From 522be41b5d59f053e0e5d75865309b8308eac598 Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 23:08:58 +0300 Subject: [PATCH 31/32] Add note to JSDoc examples --- src/typings/retryer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/typings/retryer.ts b/src/typings/retryer.ts index a5658a1..aae8673 100644 --- a/src/typings/retryer.ts +++ b/src/typings/retryer.ts @@ -37,6 +37,8 @@ export interface RetryerProps { * @param value Value of `retry.count` * @param onExceeded (defaults to `"reject"`) Limit exceed action * @example + * // (assuming that every attempt ends with retry) + * * retry.setMaxCount(0); * retry.setMaxCount(0, "reject"); * // [attempt #1] reject with "Retry limit exceeded …" From 8bc3a07c57c32408ef09f6babe7c559be87176ee Mon Sep 17 00:00:00 2001 From: Dima Parzhitsky Date: Sun, 19 Jul 2020 23:17:22 +0300 Subject: [PATCH 32/32] 1.6.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93a94e1..dee48d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@parzh/retryable", - "version": "1.5.3", + "version": "1.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d539aec..0fd7daf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@parzh/retryable", - "version": "1.5.3", + "version": "1.6.0", "description": "Convenience function to retry an action", "author": "Dima Parzhitsky ", "license": "MIT",