diff --git a/examples/retry.ts b/examples/retry.ts new file mode 100644 index 0000000..72aae12 --- /dev/null +++ b/examples/retry.ts @@ -0,0 +1,12 @@ +import { retry } from '../lib'; + +class Service { + @retry(3) + do(): Promise { + return new Promise((res, rej) => { + setTimeout(res, 1000); + }); + } +} + +const t = new Service().do().catch(err => console.log(err.message)); diff --git a/lib/retry.ts b/lib/retry/RetryOptions.ts similarity index 72% rename from lib/retry.ts rename to lib/retry/RetryOptions.ts index 356e300..de36b45 100644 --- a/lib/retry.ts +++ b/lib/retry/RetryOptions.ts @@ -26,16 +26,12 @@ export type RetryOptions = { * A custom function can be used to provide custom interval (in milliseconds) * based on attempt number (indexed from one). */ - waitPattern?: number | number[] | ((attempt: number) => number), + waitPattern?: WaitPattern, }; -/** - * Retries the execution of a method for a given number of attempts. - * If the method fails to succeed after `attempts` retries, it fails - * with error `Retry failed.` - * @param attempts max number of attempts to retry execution - * @param options (optional) retry options - */ -export function retry(attempts: number, options?: number): any { - throw new Error('Not implemented.'); -} +export type WaitPattern = number | number[] | ((attempt: number) => number); + +export const DEFAULT_ERROR = 'Retry failed.'; +export const DEFAULT_OPTIONS: RetryOptions = { + errorFilter: () => true, +}; diff --git a/lib/retry/Retryer.ts b/lib/retry/Retryer.ts new file mode 100644 index 0000000..84d5644 --- /dev/null +++ b/lib/retry/Retryer.ts @@ -0,0 +1,77 @@ +import { raiseStrategy } from '../utils'; +import { DEFAULT_ERROR, DEFAULT_OPTIONS, RetryOptions } from './RetryOptions'; +import { WaitStrategy } from './WaitStrategy'; + +export class Retryer { + private attempts: number = 0; + private readonly retryOptions: RetryOptions = { ...DEFAULT_OPTIONS, ...this.options }; + + constructor( + private readonly options: RetryOptions, + private readonly method: any, + private readonly instance: any, + private readonly retryCount: number, + ) { + this.attempts = (!this.retryCount || this.retryCount < 0) ? 0 : this.retryCount; + } + + public getResponse(): any | Promise { + try { + const response = this.method(); + const isPromiseLike = response && typeof response.then === 'function'; + + return isPromiseLike ? this.getAsyncResponse(response) : response; + } catch (err) { + const isFiltered = this.retryOptions.errorFilter.bind(this.instance)(err); + + return isFiltered ? this.retryGetSyncResponse() : this.error(); + } + } + + private retryGetSyncResponse(): any { + for (let index = 0; index < this.attempts; index += 1) { + try { + return this.method(); + } catch (err) { + const filteredError = this.retryOptions.errorFilter.bind(this.instance)(err); + + if (!filteredError) { + return this.error(); + } + } + } + + return this.error(); + } + + private async getAsyncResponse(asyncResponse: any): Promise { + for (let index = 0; index <= this.attempts; index += 1) { + await this.waitBeforeResponse(index); + + try { + return index === 0 ? await asyncResponse : await this.method(); + } catch (err) { + const filteredError = this.retryOptions.errorFilter.bind(this.instance)(err); + + if (!filteredError) { + return this.error(); + } + } + } + + return this.error(); + } + + private async waitBeforeResponse(attemptIndex: number): Promise { + if (attemptIndex > 0) { + const waitStrategy = new WaitStrategy(this.retryOptions.waitPattern); + await waitStrategy.wait(attemptIndex - 1); + } + } + + private error() { + const raise = raiseStrategy(this.retryOptions); + + return raise(new Error(DEFAULT_ERROR)); + } +} diff --git a/lib/retry/WaitStrategy.ts b/lib/retry/WaitStrategy.ts new file mode 100644 index 0000000..7efbf59 --- /dev/null +++ b/lib/retry/WaitStrategy.ts @@ -0,0 +1,37 @@ +import { WaitPattern } from './RetryOptions'; + +export class WaitStrategy { + + constructor( + private readonly waitPattern: WaitPattern, + ) { } + + public wait(index: number): Promise { + if (!this.waitPattern) { + return Promise.resolve(); + } + + const timeout = this.getTimeout(index) || 0; + return new Promise(resolve => setTimeout(resolve, timeout)); + } + + private getTimeout(index: number): number { + if (Array.isArray(this.waitPattern)) { + const values = this.waitPattern as number[]; + const count = values.length; + + return index > count ? values[count - 1] : values[index]; + } + + if (typeof this.waitPattern === 'number') { + return this.waitPattern as number; + } + + if (typeof this.waitPattern === 'function') { + return (this.waitPattern as Function)(index); + } + + throw new Error(`Option ${typeof this.waitPattern} is not supported for 'waitPattern'.`); + } + +} diff --git a/lib/retry/index.ts b/lib/retry/index.ts new file mode 100644 index 0000000..8ee9457 --- /dev/null +++ b/lib/retry/index.ts @@ -0,0 +1,27 @@ +import { Retryer } from './Retryer'; +import { RetryOptions } from './RetryOptions'; + +export { RetryOptions }; + +/** + * Retries the execution of a method for a given number of attempts. + * If the method fails to succeed after `attempts` retries, it fails + * with error `Retry failed.` + * @param attempts max number of attempts to retry execution + * @param options (optional) retry options + */ +export function retry(attempts: number, options?: RetryOptions): any { + return function (target: any, propertyKey: any, descriptor: PropertyDescriptor) { + + const method: Function = descriptor.value; + + descriptor.value = function () { + const args = arguments; + const retryer = new Retryer(options, () => method.apply(this, args), this, attempts); + + return retryer.getResponse(); + }; + + return descriptor; + }; +} diff --git a/lib/utils/index.ts b/lib/utils/index.ts new file mode 100644 index 0000000..a8e46ad --- /dev/null +++ b/lib/utils/index.ts @@ -0,0 +1,20 @@ +import { RetryOptions } from '../retry'; + +const DEFAULT_ON_ERROR = 'throw'; + +export function raiseStrategy(options: RetryOptions) { + const value = options && options.onError || DEFAULT_ON_ERROR; + + switch (value) { + case 'reject': + return err => Promise.reject(err); + case 'throw': + return (err) => { throw err; }; + case 'ignore': + return () => { }; + case 'ignoreAsync': + return () => Promise.resolve(); + default: + throw new Error(`Option ${value} is not supported for 'behavior'.`); + } +} diff --git a/test/retry/Retryer.spec.ts b/test/retry/Retryer.spec.ts new file mode 100644 index 0000000..1db271c --- /dev/null +++ b/test/retry/Retryer.spec.ts @@ -0,0 +1,108 @@ +import { expect } from 'chai'; +import { RetryOptions } from '../../lib'; +import { Retryer } from '../../lib/retry/Retryer'; + +describe('Retryer class', () => { + let retryer: Retryer; + + describe('when called method is synchrone', () => { + it('should return result', () => { + retryer = new Retryer({} as RetryOptions, () => 'Success 42!', {} as any, 3); + + expect(retryer.getResponse()).to.equal('Success 42!'); + }); + + it('should throw error with message \'Retry failed\'', () => { + retryer = new Retryer( + {} as RetryOptions, + () => { throw new Error('Failed 42'); }, + {} as any, + 3, + ); + + expect(() => retryer.getResponse()).to.throw('Retry failed.'); + }); + + it('should return result if throw\'n error is not filtered as expected', () => { + retryer = new Retryer( + { errorFilter: (err: Error) => err.message === 'Error 42.' } as RetryOptions, + () => { throw new Error('Error.'); }, + {} as any, + 3, + ); + + expect(() => retryer.getResponse()).to.throw('Retry failed.'); + }); + }); + + describe('when called method is asynchrone', () => { + it('should return result', async () => { + retryer = new Retryer({} as RetryOptions, () => Promise.resolve('Success 42!'), {} as any, 3); + const response = await retryer.getResponse(); + + expect(response).to.equal('Success 42!'); + }); + + it('should throw error with message \'Retry failed\'', async () => { + retryer = new Retryer( + {} as RetryOptions, + () => Promise.reject('Failed 42.'), + {} as any, + 3, + ); + + await expect(retryer.getResponse()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should return result if throw\'n error is not filtered as expected', async () => { + retryer = new Retryer( + { errorFilter: (err: Error) => err.message === 'Error 42.' } as RetryOptions, + () => Promise.reject('Error 42.'), + {} as any, + 3, + ); + + await expect(retryer.getResponse()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + describe('when method should wait before retry', () => { + it('should delay expected time when pattern is of type number', async () => { + retryer = new Retryer( + { waitPattern: 400 } as RetryOptions, + () => Promise.reject('Error 42.'), + {} as any, + 3, + ); + + const delay = await getFunctionDelay(async () => { + return await expect(retryer.getResponse()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + expect(delay).to.be.approximately(1200, 15); + }); + + it('should delay expected time when pattern is of type function', async () => { + retryer = new Retryer( + { waitPattern: () => { return 300; } } as RetryOptions, + () => Promise.reject('Error 42.'), + {} as any, + 3, + ); + + const delay = await getFunctionDelay(async () => { + return await expect(retryer.getResponse()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + expect(delay).to.be.approximately(900, 15); + }); + }); + }); +}); + +async function getFunctionDelay(method: Function): Promise { + const time = new Date().getTime(); + + await method(); + + return new Date().getTime() - time; +} diff --git a/test/retry/WaitStrategy.spec.ts b/test/retry/WaitStrategy.spec.ts new file mode 100644 index 0000000..cade490 --- /dev/null +++ b/test/retry/WaitStrategy.spec.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; +import { WaitStrategy } from '../../lib/retry/WaitStrategy'; + +describe('WaitStrategy class', () => { + let strategy: WaitStrategy; + + it('should delay expected time when pattern is of type number', async () => { + strategy = new WaitStrategy(400); + const delay = await getFunctionDelay(() => strategy.wait(0)); + expect(delay).to.be.approximately(400, 5); + }); + + it('should delay expected time when pattern is of type function', async () => { + strategy = new WaitStrategy(() => { return 300; }); + const delay = await getFunctionDelay(() => strategy.wait(1)); + expect(delay).to.be.approximately(300, 5); + }); + + it('should delay expected time when pattern is of type array', async () => { + strategy = new WaitStrategy([100, 300, 200]); + + let delay = await getFunctionDelay(() => strategy.wait(0)); + expect(delay).to.be.approximately(100, 5); + + delay = await getFunctionDelay(() => strategy.wait(1)); + expect(delay).to.be.approximately(300, 5); + + delay = await getFunctionDelay(() => strategy.wait(2)); + expect(delay).to.be.approximately(200, 5); + }); +}); + +async function getFunctionDelay(method: Function): Promise { + const time = new Date().getTime(); + + await method(); + + return new Date().getTime() - time; +} diff --git a/test/retry/retry.spec.ts b/test/retry/retry.spec.ts new file mode 100644 index 0000000..245d739 --- /dev/null +++ b/test/retry/retry.spec.ts @@ -0,0 +1,540 @@ +import { expect } from 'chai'; +import { retry } from '../../lib'; + +describe('@retry', () => { + describe('When method is synchrone.', () => { + describe('When method should return value.', () => { + describe('When method should work only with attempts number.', () => { + it('should resolve with expected result and attempts greater than 0.', () => { + class TestClass { + @retry(3) + test() { + return 'Success 42.'; + } + } + + const target = new TestClass(); + const result = target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts equal with 0.', () => { + class TestClass { + @retry(0) + test() { + return 'Success 42.'; + } + } + + const target = new TestClass(); + const result = target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts less than 0.', () => { + class TestClass { + @retry(-3) + test() { + return 'Success 42.'; + } + } + + const target = new TestClass(); + const result = target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts equal with null.', () => { + class TestClass { + @retry(-3) + test() { + return 'Success 42.'; + } + } + + const target = new TestClass(); + const result = target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts equal with undefined.', () => { + class TestClass { + @retry(undefined) + test() { + return 'Success 42.'; + } + } + + const target = new TestClass(); + const result = target.test(); + + expect(result).to.equal('Success 42.'); + }); + }); + }); + + describe('When method should throw error.', () => { + describe('When method should work only with attempts number.', () => { + it('should reject with expected error and attempts greater than 0.', () => { + class TestClass { + @retry(3) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + + it('should resolve with expected result and attempts equal with 0.', () => { + class TestClass { + @retry(0) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + + it('should reject with expected error and attempts less than 0.', () => { + class TestClass { + @retry(-3) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + + it('should reject with expected error and attempts equal with null.', () => { + class TestClass { + @retry(-3) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + + it('should reject with expected error and attempts equal with undefined.', () => { + class TestClass { + @retry(undefined) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + }); + + describe('When method should return a specific error.', () => { + it('should throw with expected error.', () => { + class TestClass { + @retry(3, { onError: 'throw' }) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + + it('should return undefined.', () => { + class TestClass { + @retry(3, { onError: 'ignore' }) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + const response = target.test(); + + expect(response).to.equal(undefined); + }); + }); + + describe('When method should throw for a specific error until attempts completted.', () => { + it('should throw with expected error.', () => { + class TestClass { + @retry(3, { errorFilter: (err: Error) => err.message === 'Error 42.' }) + test() { + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + }); + + it('should throw with default error and throw when error is expected.', () => { + class TestClass { + public count = 1; + @retry(3, { + errorFilter: (err: Error) => { + return err.message === 'error'; + }, + }) + test() { + if (this.count === 2) { + throw new Error('error'); + } + + this.count += 1; + throw new Error('Error 42.'); + } + } + + const target = new TestClass(); + + expect(() => target.test()).to.throw('Retry failed.'); + expect(target.count).to.equal(2); + }); + }); + }); + }); + + describe('When method is asynchrone.', () => { + describe('When method should return value.', () => { + describe('When method should work only with attempts number.', () => { + it('should resolve with expected result and attempts greater than 0.', async () => { + class TestClass { + @retry(3) + async test() { + return Promise.resolve('Success 42.'); + } + } + + const target = new TestClass(); + const result = await target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts equal with 0.', async () => { + class TestClass { + @retry(0) + async test() { + return Promise.resolve('Success 42.'); + } + } + + const target = new TestClass(); + const result = await target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts less than 0.', async () => { + class TestClass { + @retry(-3) + async test() { + return Promise.resolve('Success 42.'); + } + } + + const target = new TestClass(); + const result = await target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts equal with null.', async () => { + class TestClass { + @retry(-3) + async test() { + return Promise.resolve('Success 42.'); + } + } + + const target = new TestClass(); + const result = await target.test(); + + expect(result).to.equal('Success 42.'); + }); + + it('should resolve with expected result and attempts equal with undefined.', async () => { + class TestClass { + @retry(undefined) + async test() { + return Promise.resolve('Success 42.'); + } + } + + const target = new TestClass(); + const result = await target.test(); + + expect(result).to.equal('Success 42.'); + }); + }); + }); + + describe('When method should throw error.', () => { + describe('When method should work only with attempts number.', () => { + it('should reject with expected error and attempts greater than 0.', async () => { + class TestClass { + @retry(3) + async test() { + return Promise.reject('Error 42.'); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should resolve with expected result and attempts equal with 0.', async () => { + class TestClass { + @retry(0) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should reject with expected error and attempts less than 0.', async () => { + class TestClass { + @retry(-3) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should reject with expected error and attempts equal with null.', async () => { + class TestClass { + @retry(-3) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should reject with expected error and attempts equal with undefined.', async () => { + class TestClass { + @retry(undefined) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + }); + + describe('When method should return a specific error.', () => { + it('should throw with expected error.', async () => { + class TestClass { + @retry(3, { onError: 'reject' }) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should return undefined.', async () => { + class TestClass { + @retry(3, { onError: 'ignoreAsync' }) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + const response = await target.test(); + + expect(response).to.equal(undefined); + }); + }); + + // tslint:disable-next-line:max-line-length + describe('When method should reject for a specific error until attempts completted.', () => { + it('should throw with expected error.', async () => { + class TestClass { + @retry(3, { errorFilter: (err: Error) => err.message === 'Error 42.' }) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + }); + + it('should throw with default error and throw when error is expected.', async () => { + class TestClass { + public count = 1; + @retry(3, { + errorFilter: (err: Error) => { + return err.message === 'error'; + }, + }) + async test() { + if (this.count === 2) { + return Promise.reject(new Error('error')); + } + + this.count += 1; + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + expect(target.count).to.equal(2); + }); + }); + + describe('When method should work correctly with wait pattern', () => { + it('should wait for a specific time when pattern is number', async () => { + class TestClass { + private count = 1; + private now; + public times = []; + @retry(3, { + waitPattern: 100, + }) + async test() { + if (this.count === 1) { + this.now = new Date().getTime(); + } else { + const newTime = new Date().getTime() - this.now; + + this.times.push(newTime); + this.now = new Date().getTime(); + } + this.count += 1; + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + target.times.forEach((time) => { + expect(time).to.be.approximately(100, 5); + }); + }); + + it('should wait for a specific time when pattern is array of numbers', async () => { + class TestClass { + private count = 1; + private now; + public times = []; + @retry(3, { + waitPattern: [100, 200, 300], + }) + async test() { + if (this.count === 1) { + this.now = new Date().getTime(); + } else { + const newTime = new Date().getTime() - this.now; + + this.times.push(newTime); + this.now = new Date().getTime(); + } + this.count += 1; + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + target.times.forEach((time, index) => { + expect(time).to.be.approximately(100 * (index + 1), 5); + }); + }); + + it('should wait for a specific time when pattern is function', async () => { + class TestClass { + private count = 0; + private now; + public times = []; + @retry(3, { + waitPattern: attempt => attempt * 100, + }) + async test() { + if (this.count <= 1) { + this.now = new Date().getTime(); + } else { + const newTime = new Date().getTime() - this.now; + + this.times.push(newTime); + this.now = new Date().getTime(); + } + this.count += 1; + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + await expect(target.test()).to.eventually.be.rejectedWith('Retry failed.'); + target.times.forEach((time, index) => { + expect(time).to.be.approximately(100 * (index + 1), 5); + }); + }); + + it('should reject error if type of wait pattern is wrong', async () => { + class TestClass { + public times = []; + @retry(3, { + waitPattern: 'wait' as any, + }) + async test() { + return Promise.reject(new Error('Error 42.')); + } + } + + const target = new TestClass(); + + // tslint:disable-next-line:max-line-length + await expect(target.test()).to.eventually.be.rejectedWith('Option string is not supported for \'waitPattern\'.'); + }); + }); + }); + }); +});