Skip to content
12 changes: 12 additions & 0 deletions examples/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { retry } from '../lib';

class Service {
@retry(3)
do(): Promise<number> {
return new Promise((res, rej) => {
setTimeout(res, 1000);
});
}
}

const t = new Service().do().catch(err => console.log(err.message));
18 changes: 7 additions & 11 deletions lib/retry.ts → lib/retry/RetryOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
77 changes: 77 additions & 0 deletions lib/retry/Retryer.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<any> {
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<void> {
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));
}
}
37 changes: 37 additions & 0 deletions lib/retry/WaitStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { WaitPattern } from './RetryOptions';

export class WaitStrategy {

constructor(
private readonly waitPattern: WaitPattern,
) { }

public wait(index: number): Promise<void> {
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];
Comment thread
nicolaecaliman marked this conversation as resolved.
}

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'.`);
}

}
27 changes: 27 additions & 0 deletions lib/retry/index.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
20 changes: 20 additions & 0 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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'.`);
}
}
108 changes: 108 additions & 0 deletions test/retry/Retryer.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const time = new Date().getTime();

await method();

return new Date().getTime() - time;
}
39 changes: 39 additions & 0 deletions test/retry/WaitStrategy.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const time = new Date().getTime();

await method();

return new Date().getTime() - time;
}
Loading