From 11410d39bb3b0a9a3a3fb4e220472da1003092a9 Mon Sep 17 00:00:00 2001 From: Soma Lucz Date: Mon, 16 Mar 2020 23:17:40 +0100 Subject: [PATCH] feat: Implement FallbackPolicy Closes #185. --- src/main.ts | 2 + .../fallbackChainExhaustedException.ts | 1 + .../fallbackPolicy/fallbackChainLink.ts | 1 + .../reactive/fallbackPolicy/fallbackPolicy.ts | 112 +++++++++ .../reactive/fallbackPolicy/onFallbackFn.ts | 4 + .../reactive/retryPolicy/backoffStrategy.ts | 1 + .../reactive/retryPolicy/onRetryFn.ts | 5 + .../reactive/retryPolicy/retryPolicy.ts | 72 ++---- src/types/onFinallyFn.ts | 1 + test/specs/fallbackPolicy.test.ts | 233 ++++++++++++++++++ test/specs/retryPolicy.test.ts | 28 ++- 11 files changed, 403 insertions(+), 57 deletions(-) create mode 100644 src/policies/reactive/fallbackPolicy/fallbackChainExhaustedException.ts create mode 100644 src/policies/reactive/fallbackPolicy/fallbackChainLink.ts create mode 100644 src/policies/reactive/fallbackPolicy/fallbackPolicy.ts create mode 100644 src/policies/reactive/fallbackPolicy/onFallbackFn.ts create mode 100644 src/policies/reactive/retryPolicy/backoffStrategy.ts create mode 100644 src/policies/reactive/retryPolicy/onRetryFn.ts create mode 100644 src/types/onFinallyFn.ts create mode 100644 test/specs/fallbackPolicy.test.ts diff --git a/src/main.ts b/src/main.ts index b9d40f0..3b9f08f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ export { TimeoutException } from './policies/proactive/timeoutPolicy/timeoutException'; export { TimeoutPolicy } from './policies/proactive/timeoutPolicy/timeoutPolicy'; +export { FallbackChainExhaustedException } from './policies/reactive/fallbackPolicy/fallbackChainExhaustedException'; +export { FallbackPolicy } from './policies/reactive/fallbackPolicy/fallbackPolicy'; export { BackoffStrategyFactory } from './policies/reactive/retryPolicy/backoffStrategyFactory'; export { RetryPolicy } from './policies/reactive/retryPolicy/retryPolicy'; diff --git a/src/policies/reactive/fallbackPolicy/fallbackChainExhaustedException.ts b/src/policies/reactive/fallbackPolicy/fallbackChainExhaustedException.ts new file mode 100644 index 0000000..c245e28 --- /dev/null +++ b/src/policies/reactive/fallbackPolicy/fallbackChainExhaustedException.ts @@ -0,0 +1 @@ +export class FallbackChainExhaustedException extends Error {} diff --git a/src/policies/reactive/fallbackPolicy/fallbackChainLink.ts b/src/policies/reactive/fallbackPolicy/fallbackChainLink.ts new file mode 100644 index 0000000..ffd0819 --- /dev/null +++ b/src/policies/reactive/fallbackPolicy/fallbackChainLink.ts @@ -0,0 +1 @@ +export type FallbackChainLink = () => ResultType | Promise; diff --git a/src/policies/reactive/fallbackPolicy/fallbackPolicy.ts b/src/policies/reactive/fallbackPolicy/fallbackPolicy.ts new file mode 100644 index 0000000..30c2070 --- /dev/null +++ b/src/policies/reactive/fallbackPolicy/fallbackPolicy.ts @@ -0,0 +1,112 @@ +import { OnFinallyFn } from '../../../types/onFinallyFn'; +import { ReactivePolicy } from '../reactivePolicy'; +import { FallbackChainExhaustedException } from './fallbackChainExhaustedException'; +import { FallbackChainLink } from './fallbackChainLink'; +import { OnFallbackFn } from './onFallbackFn'; + +export class FallbackPolicy extends ReactivePolicy { + private readonly fallbackChain: Array> = []; + private readonly onFallbackFns: Array> = []; + private readonly onFinallyFns: OnFinallyFn[] = []; + private executing = 0; + + public fallback(fallbackChainLink: FallbackChainLink): void { + if (this.executing > 0) { + throw new Error('cannot modify policy during execution'); + } + + this.fallbackChain.push(fallbackChainLink); + } + + public onFallback(onFallbackFn: OnFallbackFn): void { + if (this.executing > 0) { + throw new Error('cannot modify policy during execution'); + } + + this.onFallbackFns.push(onFallbackFn); + } + + public onFinally(fn: OnFinallyFn): void { + if (this.executing > 0) { + throw new Error('cannot modify policy during execution'); + } + + this.onFinallyFns.push(fn); + } + + public async execute(fn: () => ResultType | Promise): Promise { + try { + this.executing++; + + const remainingFallbackChain = [...this.fallbackChain]; + let executor = fn; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const result = await executor(); + + const shouldFallbackOnResult = await this.isResultHandled(result); + if (!shouldFallbackOnResult) { + return result; + } + + const nextExecutor = remainingFallbackChain.shift(); + if (nextExecutor === undefined) { + throw new FallbackChainExhaustedException(); + } + + executor = nextExecutor; + + for (const onFallbackFn of this.onFallbackFns) { + try { + await onFallbackFn(result, undefined); + } catch (onFallbackError) { + // ignored + } + } + + continue; + } catch (ex) { + if (ex instanceof FallbackChainExhaustedException) { + throw ex; + } + + const shouldFallbackOnException = await this.isExceptionHandled(ex); + if (!shouldFallbackOnException) { + throw ex; + } + + const nextExecutor = remainingFallbackChain.shift(); + if (nextExecutor === undefined) { + throw new FallbackChainExhaustedException(); + } + + executor = nextExecutor; + + for (const onFallbackFn of this.onFallbackFns) { + try { + await onFallbackFn(undefined, ex); + } catch (onFallbackError) { + // ignored + } + } + + continue; + } + } + } finally { + try { + for (const onFinallyFn of this.onFinallyFns) { + try { + await onFinallyFn(); + } catch (onFinallyError) { + // ignored + } + } + } finally { + this.executing--; + } + } + } +} diff --git a/src/policies/reactive/fallbackPolicy/onFallbackFn.ts b/src/policies/reactive/fallbackPolicy/onFallbackFn.ts new file mode 100644 index 0000000..cdd7487 --- /dev/null +++ b/src/policies/reactive/fallbackPolicy/onFallbackFn.ts @@ -0,0 +1,4 @@ +export type OnFallbackFn = ( + result: ResultType | undefined, + error: unknown | undefined, +) => void | Promise; diff --git a/src/policies/reactive/retryPolicy/backoffStrategy.ts b/src/policies/reactive/retryPolicy/backoffStrategy.ts new file mode 100644 index 0000000..06a52a4 --- /dev/null +++ b/src/policies/reactive/retryPolicy/backoffStrategy.ts @@ -0,0 +1 @@ +export type BackoffStrategy = (currentRetryCount: number) => number | Promise; diff --git a/src/policies/reactive/retryPolicy/onRetryFn.ts b/src/policies/reactive/retryPolicy/onRetryFn.ts new file mode 100644 index 0000000..e24e3cf --- /dev/null +++ b/src/policies/reactive/retryPolicy/onRetryFn.ts @@ -0,0 +1,5 @@ +export type OnRetryFn = ( + result: ResultType | undefined, + error: unknown | undefined, + currentRetryCount: number, +) => void | Promise; diff --git a/src/policies/reactive/retryPolicy/retryPolicy.ts b/src/policies/reactive/retryPolicy/retryPolicy.ts index d8aa44c..f3a1f66 100644 --- a/src/policies/reactive/retryPolicy/retryPolicy.ts +++ b/src/policies/reactive/retryPolicy/retryPolicy.ts @@ -1,13 +1,13 @@ -import { Predicate } from '../../../types/predicate'; +import { OnFinallyFn } from '../../../types/onFinallyFn'; import { ReactivePolicy } from '../reactivePolicy'; +import { BackoffStrategy } from './backoffStrategy'; +import { OnRetryFn } from './onRetryFn'; export class RetryPolicy extends ReactivePolicy { private totalRetryCount = 1; - private readonly onRetryFns: Array< - (result: ResultType | undefined, error: unknown | undefined, currentRetryCount: number) => void | Promise - > = []; - private backoffStrategy: (currentRetryCount: number) => number | Promise = (): number => 0; - private readonly onFinallyFns: Array<() => void | Promise> = []; + private readonly onRetryFns: Array> = []; + private backoffStrategy: BackoffStrategy = (): number => 0; + private readonly onFinallyFns: OnFinallyFn[] = []; private executing = 0; public constructor() { @@ -42,13 +42,7 @@ export class RetryPolicy extends ReactivePolicy { this.totalRetryCount = Number.POSITIVE_INFINITY; } - public onRetry( - fn: ( - result: ResultType | undefined, - error: unknown | undefined, - currentRetryCount: number, - ) => void | Promise, - ): void { + public onRetry(fn: OnRetryFn): void { if (this.executing > 0) { throw new Error('cannot modify policy during execution'); } @@ -56,7 +50,7 @@ export class RetryPolicy extends ReactivePolicy { this.onRetryFns.push(fn); } - public waitBeforeRetry(strategy: (currentRetryCount: number) => number | Promise): void { + public waitBeforeRetry(strategy: BackoffStrategy): void { if (this.executing > 0) { throw new Error('cannot modify policy during execution'); } @@ -64,7 +58,7 @@ export class RetryPolicy extends ReactivePolicy { this.backoffStrategy = strategy; } - public onFinally(fn: () => void | Promise): void { + public onFinally(fn: OnFinallyFn): void { if (this.executing > 0) { throw new Error('cannot modify policy during execution'); } @@ -73,9 +67,9 @@ export class RetryPolicy extends ReactivePolicy { } public async execute(fn: () => ResultType | Promise): Promise { - this.executing++; - try { + this.executing++; + let currentRetryCount = 0; // eslint-disable-next-line no-constant-condition @@ -83,9 +77,13 @@ export class RetryPolicy extends ReactivePolicy { try { const result = await fn(); + const shouldRetryOnResult = await this.isResultHandled(result); + if (!shouldRetryOnResult) { + return result; + } + currentRetryCount++; - const shouldRetry = await this.shouldRetryOnResult(result, currentRetryCount); - if (!shouldRetry) { + if (!this.hasRetryLeft(currentRetryCount)) { return result; } @@ -102,9 +100,13 @@ export class RetryPolicy extends ReactivePolicy { continue; } catch (ex) { + const shouldRetryOnException = await this.isExceptionHandled(ex); + if (!shouldRetryOnException) { + throw ex; + } + currentRetryCount++; - const shouldRetry = await this.shouldRetryOnException(ex, currentRetryCount); - if (!shouldRetry) { + if (!this.hasRetryLeft(currentRetryCount)) { throw ex; } @@ -137,32 +139,8 @@ export class RetryPolicy extends ReactivePolicy { } } - private async shouldRetryOnResult(result: ResultType, currentRetryCount: number): Promise { - return this.shouldRetryOn( - currentRetryCount, - result, - async (result): Promise => this.isResultHandled(result), - ); - } - - private async shouldRetryOnException(exception: unknown, currentRetryCount: number): Promise { - return this.shouldRetryOn( - currentRetryCount, - exception, - async (exception): Promise => this.isExceptionHandled(exception), - ); - } - - private async shouldRetryOn( - currentRetryCount: number, - subject: T, - shouldRetryCb: Predicate, - ): Promise { - if (currentRetryCount > this.totalRetryCount) { - return false; - } - - return shouldRetryCb(subject); + private hasRetryLeft(currentRetryCount: number): boolean { + return currentRetryCount <= this.totalRetryCount; } private async waitFor(ms: number): Promise { diff --git a/src/types/onFinallyFn.ts b/src/types/onFinallyFn.ts new file mode 100644 index 0000000..cc6a174 --- /dev/null +++ b/src/types/onFinallyFn.ts @@ -0,0 +1 @@ +export type OnFinallyFn = () => void | Promise; diff --git a/test/specs/fallbackPolicy.test.ts b/test/specs/fallbackPolicy.test.ts new file mode 100644 index 0000000..e6760fc --- /dev/null +++ b/test/specs/fallbackPolicy.test.ts @@ -0,0 +1,233 @@ +import { expect } from 'chai'; +import { FallbackChainExhaustedException } from '../../src/policies/reactive/fallbackPolicy/fallbackChainExhaustedException'; +import { FallbackPolicy } from '../../src/policies/reactive/fallbackPolicy/fallbackPolicy'; + +describe('FallbackPolicy', (): void => { + it('should run the synchronous execution callback and return its result by default', async (): Promise => { + const policy = new FallbackPolicy(); + const result = await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + + expect(result).to.equal('Diplomatiq is cool.'); + }); + + it('should run the asynchronous execution callback and return its result by default', async (): Promise => { + const policy = new FallbackPolicy(); + const result = await policy.execute( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + return 'Diplomatiq is cool.'; + }, + ); + + expect(result).to.equal('Diplomatiq is cool.'); + }); + + it('should run the synchronous execution callback and throw its exceptions by default', async (): Promise => { + const policy = new FallbackPolicy(); + + try { + await policy.execute((): string => { + throw new Error('TestException'); + }); + expect.fail('did not throw'); + } catch (ex) { + expect((ex as Error).message).to.equal('TestException'); + } + }); + + it('should run the asynchronous execution callback and throw its exceptions by default', async (): Promise< + void + > => { + const policy = new FallbackPolicy(); + + try { + await policy.execute((): unknown => { + throw new Error('TestException'); + }); + expect.fail('did not throw'); + } catch (ex) { + expect((ex as Error).message).to.equal('TestException'); + } + }); + + it('should fallback on a given (i.e. wrong) result, then return the result of the synchronous fallback function', async (): Promise< + void + > => { + const policy = new FallbackPolicy(); + policy.handleResult((r: string): boolean => r === 'Diplomatiq is cool.'); + policy.fallback((): string => { + return 'Diplomatiq is the coolest.'; + }); + + const result = await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + + expect(result).to.equal('Diplomatiq is the coolest.'); + }); + + it('should fallback on a given (i.e. wrong) result, then return the result of the asynchronous fallback function', async (): Promise< + void + > => { + const policy = new FallbackPolicy(); + policy.handleResult((r: string): boolean => r === 'Diplomatiq is cool.'); + policy.fallback( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + return 'Diplomatiq is the coolest.'; + }, + ); + + const result = await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + + expect(result).to.equal('Diplomatiq is the coolest.'); + }); + + it('should not fallback on a not given (i.e. right) result, but return the result', async (): Promise => { + const policy = new FallbackPolicy(); + policy.handleResult((r: string): boolean => r === 'Diplomatiq is not cool.'); + + const result = await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + + expect(result).to.equal('Diplomatiq is cool.'); + }); + + it('should fallback along a synchronous fallback chain sequentially while it produces the given (i.e. wrong) result until the first not given (i.e. good) result is produced', async (): Promise< + void + > => { + const policy = new FallbackPolicy(); + + let fallbacksExecuted = 0; + + policy.handleResult((r: string): boolean => r === 'Diplomatiq is cool.'); + + policy.fallback((): string => { + expect(fallbacksExecuted).to.equal(0); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(1); + return 'Diplomatiq is cool.'; + }); + policy.fallback((): string => { + expect(fallbacksExecuted).to.equal(1); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(2); + return 'Diplomatiq is cool.'; + }); + policy.fallback((): string => { + expect(fallbacksExecuted).to.equal(2); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(3); + return 'Diplomatiq is the coolest.'; + }); + + const result = await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + + expect(result).to.equal('Diplomatiq is the coolest.'); + expect(fallbacksExecuted).to.equal(3); + }); + + it('should fallback along an asynchronous fallback chain sequentially while it produces the given (i.e. wrong) result, until the first not given (i.e. good) result is produced', async (): Promise< + void + > => { + const policy = new FallbackPolicy(); + + let fallbacksExecuted = 0; + + policy.handleResult((r: string): boolean => r === 'Diplomatiq is cool.'); + + policy.fallback( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + expect(fallbacksExecuted).to.equal(0); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(1); + return 'Diplomatiq is cool.'; + }, + ); + policy.fallback( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + expect(fallbacksExecuted).to.equal(1); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(2); + return 'Diplomatiq is cool.'; + }, + ); + policy.fallback( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + expect(fallbacksExecuted).to.equal(2); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(3); + return 'Diplomatiq is the coolest.'; + }, + ); + + const result = await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + + expect(result).to.equal('Diplomatiq is the coolest.'); + expect(fallbacksExecuted).to.equal(3); + }); + + it('should throw FallbackChainExhaustedException if there are no (more) links on the fallback chain', async (): Promise< + void + > => { + const policy = new FallbackPolicy(); + policy.handleResult((r: string): boolean => r === 'Diplomatiq is cool.'); + + try { + await policy.execute((): string => { + return 'Diplomatiq is cool.'; + }); + expect.fail('did not throw'); + } catch (ex) { + expect(ex instanceof FallbackChainExhaustedException).to.be.true; + } + }); + + it('should fallback on multiple given (i.e. wrong) results if any of them occurs', async (): Promise => { + const policy = new FallbackPolicy(); + + let fallbacksExecuted = 0; + + policy.handleResult((r: string): boolean => r === 'Diplomatiq is not cool.'); + policy.handleResult((r: string): boolean => r === 'Diplomatiq is bad.'); + policy.handleResult((r: string): boolean => r === 'Diplomatiq is the worst.'); + + policy.fallback((): string => { + expect(fallbacksExecuted).to.equal(0); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(1); + return 'Diplomatiq is bad.'; + }); + policy.fallback((): string => { + expect(fallbacksExecuted).to.equal(1); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(2); + return 'Diplomatiq is the worst.'; + }); + policy.fallback((): string => { + expect(fallbacksExecuted).to.equal(2); + fallbacksExecuted++; + expect(fallbacksExecuted).to.equal(3); + return 'Diplomatiq is cool.'; + }); + + const result = await policy.execute((): string => { + return 'Diplomatiq is not cool.'; + }); + + expect(fallbacksExecuted).to.equal(3); + expect(result).to.equal('Diplomatiq is cool.'); + }); +}); diff --git a/test/specs/retryPolicy.test.ts b/test/specs/retryPolicy.test.ts index 15ae45b..d603907 100644 --- a/test/specs/retryPolicy.test.ts +++ b/test/specs/retryPolicy.test.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { RetryPolicy } from '../../src/policies/reactive/retryPolicy/retryPolicy'; describe('RetryPolicy', (): void => { - it('should run the execution callback and return its result by default', async (): Promise => { + it('should run the synchronous execution callback and return its result by default', async (): Promise => { const policy = new RetryPolicy(); const result = await policy.execute((): string => { return 'Diplomatiq is cool.'; @@ -11,16 +11,19 @@ describe('RetryPolicy', (): void => { expect(result).to.equal('Diplomatiq is cool.'); }); - it('should run the async execution callback and return its result by default', async (): Promise => { + it('should run the asynchronous execution callback and return its result by default', async (): Promise => { const policy = new RetryPolicy(); - const result = await policy.execute((): string => { - return 'Diplomatiq is cool.'; - }); + const result = await policy.execute( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + return 'Diplomatiq is cool.'; + }, + ); expect(result).to.equal('Diplomatiq is cool.'); }); - it('should run the execution callback and throw its exceptions by default', async (): Promise => { + it('should run the synchronous execution callback and throw its exceptions by default', async (): Promise => { const policy = new RetryPolicy(); try { @@ -33,13 +36,18 @@ describe('RetryPolicy', (): void => { } }); - it('should run the async execution callback and throw its exceptions by default', async (): Promise => { + it('should run the asynchronous execution callback and throw its exceptions by default', async (): Promise< + void + > => { const policy = new RetryPolicy(); try { - await policy.execute((): unknown => { - throw new Error('TestException'); - }); + await policy.execute( + // eslint-disable-next-line @typescript-eslint/require-await + async (): Promise => { + throw new Error('TestException'); + }, + ); expect.fail('did not throw'); } catch (ex) { expect((ex as Error).message).to.equal('TestException');