Skip to content

Commit 31826d8

Browse files
author
Ovidiu Barabula
committed
feat(util): add sleep, getFnName and retry utility functions and improve error handling
1 parent 63cbdc8 commit 31826d8

File tree

2 files changed

+320
-9
lines changed

2 files changed

+320
-9
lines changed

src/util/utility-functions.spec.ts

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import * as chai from 'chai';
2+
import * as chaiAsPromised from 'chai-as-promised';
3+
chai.use(chaiAsPromised);
4+
15
import { assert, expect } from 'chai';
26
import 'mocha';
3-
import { ERRORS, hasNested, limitFn, range } from './utility-functions';
7+
import { stdout } from 'test-console';
8+
import { ERRORS, getFnName, hasNested, limitFn, range, retry, sleep } from './utility-functions';
49

510
describe('Utility Functions', () => {
6-
describe('hasNested', () => {
11+
describe('hasNested()', () => {
712
it('returns true if object has key', () => {
813
const object = {
914
key: 'value',
@@ -48,10 +53,25 @@ describe('Utility Functions', () => {
4853
it('returns false if passed empty path string', () => {
4954
expect(hasNested({}, '')).to.be.false;
5055
});
56+
57+
58+
it('throws if first parameter is not an object', () => {
59+
assert.throws(() => hasNested(undefined, 'key'), ERRORS.HAS_NESTED_NOT_AN_OBJECT);
60+
});
61+
62+
63+
it('throws if first parameter is an array', () => {
64+
assert.throws(() => hasNested(['value'], 'key'), ERRORS.HAS_NESTED_NOT_AN_OBJECT);
65+
});
66+
67+
68+
it('throws if second parameter is not a string or an array', () => {
69+
assert.throws(() => hasNested({}, undefined), ERRORS.HAS_NESTED_NOT_A_STRING);
70+
});
5171
});
5272

5373

54-
describe('limitFn', () => {
74+
describe('limitFn()', () => {
5575
it('returns a function', () => {
5676
const fn = () => true;
5777
expect(limitFn(fn)).to.be.a('function');
@@ -102,7 +122,7 @@ describe('Utility Functions', () => {
102122
});
103123

104124

105-
describe('range', () => {
125+
describe('range()', () => {
106126
it('creates an array from 1 to 5', () => {
107127
const subject = range(1, 5);
108128
expect(subject).to.be.an('array');
@@ -149,4 +169,154 @@ describe('Utility Functions', () => {
149169
assert.throws(() => range(2, 2), ERRORS.RANGE_LIMITS_EQUAL);
150170
});
151171
});
172+
173+
174+
describe('getFnName()', () => {
175+
it('returns function name', () => {
176+
const myFunction = () => true;
177+
expect(getFnName(myFunction)).to.equal('function \'myFunction\'');
178+
});
179+
180+
181+
it('returns anonymous function name', () => {
182+
expect(getFnName(() => true)).to.equal('\'anonymous\' function');
183+
});
184+
});
185+
186+
187+
describe('sleep()', () => {
188+
it('resolves after a 1000ms (default)', async () => {
189+
const startTime = Date.now();
190+
await sleep();
191+
expect((Date.now() - startTime)).to.be.gte(1000 * 0.9);
192+
}).timeout(5000);
193+
194+
195+
it('resolves after a 500ms', async () => {
196+
const startTime = Date.now();
197+
await sleep(500);
198+
expect((Date.now() - startTime)).to.be.gte(500 * 0.9);
199+
}).timeout(5000);
200+
201+
202+
it('resolves after a 250ms', async () => {
203+
const startTime = Date.now();
204+
await sleep(250);
205+
expect((Date.now() - startTime)).to.be.gte(250 * 0.9);
206+
}).timeout(5000);
207+
});
208+
209+
210+
describe('retry()', () => {
211+
// Helper function to create error prone functions
212+
function badFn(fnName: string = 'errorProneFn', workOn?: number, errorNumber: number = 1) {
213+
const innerFn = function () {
214+
if (workOn && workOn === errorNumber - 1) {
215+
return true;
216+
}
217+
throw new Error(String(errorNumber++));
218+
};
219+
220+
const api = { [fnName]: innerFn };
221+
Object.defineProperty(api[fnName], 'name', { value: fnName });
222+
return api;
223+
}
224+
225+
it('calls passed in function', async () => {
226+
let wasCalled = false;
227+
await retry(() => wasCalled = true);
228+
expect(wasCalled).to.be.true;
229+
});
230+
231+
232+
it('returns the function\'s return value', () => {
233+
const returnValue = 'It returns correctly!';
234+
const fn = () => returnValue;
235+
return expect(retry(fn)).to.eventually.be.equal(returnValue);
236+
});
237+
238+
239+
it('works with an empty options object', () => {
240+
return expect(retry(() => true, {})).to.be.eventually.true;
241+
});
242+
243+
244+
it('ignores bad options', () => {
245+
return expect(retry(() => true, { badOption: true })).to.be.eventually.true;
246+
});
247+
248+
249+
it('retries 3 times (by default) and returns the last error', () => {
250+
const { alwaysErrors } = badFn('alwaysErrors');
251+
return expect(retry(alwaysErrors, { delay: 100 })).to.be.rejectedWith(/3/);
252+
}).timeout(5000);
253+
254+
255+
it('retries a custom number of times before it returns the error', () => {
256+
const { errorProneFn } = badFn(undefined, 2);
257+
return expect(retry(errorProneFn, { delay: 100, retries: 2 })).to.be.rejectedWith(/2/);
258+
}).timeout(5000);
259+
260+
261+
it('retries 1 time and returns function\'s return value', () => {
262+
const { fnErrorsOnce } = badFn('fnErrorsOnce', 1);
263+
return expect(retry(fnErrorsOnce)).to.be.fulfilled;
264+
}).timeout(5000);
265+
266+
it('throws the error of a rejected promise', () => {
267+
const rejectPromiseFn = () => Promise.reject(new Error('Intentionally rejected promise'));
268+
return expect(retry(rejectPromiseFn, { delay: 100 }))
269+
.to.be.rejectedWith(RegExp('Intentionally rejected promise'));
270+
}).timeout(5000);
271+
272+
273+
it('throws if first parameter <fn> is not a function', () => {
274+
return expect(retry(undefined)).to.be.rejectedWith(ERRORS.RETRY_NEEDS_A_FUNCTION);
275+
});
276+
277+
278+
it('throws if second parameter <options> is not an object', () => {
279+
return expect(retry(() => true, 1)).to.be.rejectedWith(ERRORS.RETRY_NEEDS_OPTIONS_TO_BE_OBJECT);
280+
});
281+
282+
283+
it('throws if option <delay> is 0', () => {
284+
return expect(retry(() => true, { delay: 0 })).to.be.rejectedWith(ERRORS.RETRY_DELAY_CANNOT_BE_ZERO);
285+
});
286+
287+
288+
it('throws if option <retries> is 0', () => {
289+
return expect(retry(() => true, { retries: 0 })).to.be.rejectedWith(ERRORS.RETRY_RETRIES_CANNOT_BE_ZERO);
290+
});
291+
292+
293+
describe('retry() logging', () => {
294+
let inspect: any;
295+
296+
beforeEach(() => inspect = stdout.inspect());
297+
afterEach(() => {
298+
inspect.restore();
299+
300+
// Console out the output from the console.log stub
301+
for (const output of inspect.output) {
302+
// Clean up by removing new line characters
303+
console.log(output.replace('\n', ''));
304+
}
305+
});
306+
307+
308+
it('accepts custom logger namespace', async () => {
309+
const { alwaysErrorsFn } = badFn('alwaysErrorsFn');
310+
await retry(alwaysErrorsFn, { logNamespace: 'customNamespace', retries: 1 }).catch(ignore => undefined);
311+
expect(inspect.output.join(' ')).to.have.string('customNamespace');
312+
}).timeout(5000);
313+
314+
315+
it('accepts custom logger channel', async () => {
316+
const { alwaysErrorsFn } = badFn('alwaysErrorsFn');
317+
await retry(alwaysErrorsFn, { logChannel: 'customChannel', retries: 1 }).catch(ignore => undefined);
318+
expect(inspect.output.join(' ')).to.have.string('customChannel');
319+
}).timeout(5000);
320+
});
321+
});
152322
});

src/util/utility-functions.ts

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* @since 0.1.0
66
*/
77

8+
import Logger, { ILogger } from './logger';
9+
810
export interface NestedObject {
911
[key: string]: any;
1012
}
@@ -13,11 +15,24 @@ export type AnyFunction = (...args: any[]) => any;
1315

1416

1517
export const ERRORS = {
16-
LIMIT_NOT_A_NUMBER: 'Limit argument must be a number',
17-
NOT_A_FUNCTION: 'Passed argument is not a function',
18-
RANGE_LIMITS_EQUAL: 'Range limits cannot be equal',
19-
RANGE_NEEDS_NUMBERS: 'Range limit arguments must be numbers',
20-
RANGE_NEEDS_TWO_PARAMS: 'Range requires two number parameters',
18+
// hasNested()
19+
HAS_NESTED_NOT_AN_OBJECT: 'hasNested() requires first argument <object> to be of type \'object\'',
20+
HAS_NESTED_NOT_A_STRING: 'hasNested() requires second argument <path> to be of type \'string\' or \'array\' of strings',
21+
22+
// limitFn()
23+
LIMIT_NOT_A_NUMBER: 'limitFn() requires second argument to be of type \'number\'',
24+
NOT_A_FUNCTION: 'limitFn() requires first argument to be of type \'function\'',
25+
26+
// range()
27+
RANGE_LIMITS_EQUAL: 'range() limits cannot be equal',
28+
RANGE_NEEDS_NUMBERS: 'range() requires both arguments to be of type \'number\'',
29+
RANGE_NEEDS_TWO_PARAMS: 'range() requires two arguments of type \'number\'',
30+
31+
// retry()
32+
RETRY_DELAY_CANNOT_BE_ZERO: 'retry() option <delay> cannot be 0ms',
33+
RETRY_NEEDS_A_FUNCTION: 'retry() requires first argument <fn> to be of type \'function\'',
34+
RETRY_NEEDS_OPTIONS_TO_BE_OBJECT: 'retry() requires second argument <options> to be of type \'object\'',
35+
RETRY_RETRIES_CANNOT_BE_ZERO: 'retry() option <retries> cannot be 0',
2136
};
2237

2338

@@ -27,6 +42,15 @@ export const ERRORS = {
2742
* @param path Dot notation path, regular key string or array of keys
2843
*/
2944
export function hasNested(object: NestedObject, path: string | string[]): boolean {
45+
// Arguments validation
46+
if (typeof object !== 'object' || Array.isArray(object)) {
47+
throw new Error(ERRORS.HAS_NESTED_NOT_AN_OBJECT);
48+
}
49+
50+
if (typeof path !== 'string' && !Array.isArray(path)) {
51+
throw new Error(ERRORS.HAS_NESTED_NOT_A_STRING);
52+
}
53+
3054
// If path is dot notation (e.g. 'object.child.subchild')
3155
if (typeof path === 'string' && path.includes('.')) {
3256
path = path.split('.');
@@ -105,3 +129,120 @@ export function range(from: number, to: number): number[] {
105129
// This keeps the above loop the same in both cases
106130
return direction > 0 ? array : array.reverse();
107131
}
132+
133+
134+
/**
135+
* Resolves a promise after a custom amount of time
136+
* @param ms Number of miliseconds to sleep
137+
*/
138+
export function sleep(ms: number = 1000): Promise<void> {
139+
return new Promise(resolve => setTimeout(resolve, ms));
140+
}
141+
142+
143+
/**
144+
* Get function name
145+
*/
146+
export function getFnName(fn: AnyFunction): string {
147+
return fn.name
148+
? `function '${fn.name}'`
149+
: '\'anonymous\' function';
150+
}
151+
152+
153+
/**
154+
* Takes a function and calls it, when and if it fails,
155+
* it retries until it reaches max number of retries
156+
* @param fn Function to be called
157+
* @param options Options object
158+
* @return Function's return value or last error
159+
*/
160+
export async function retry(
161+
fn: AnyFunction,
162+
options: {
163+
// Delay in miliseconds until retry
164+
delay?: number,
165+
// Maximum number of retries
166+
retries?: number,
167+
// Custom log channel
168+
logChannel?: string,
169+
// Custom log namespace
170+
logNamespace?: string,
171+
} = {},
172+
): Promise<any> {
173+
// Arguments validation
174+
if (typeof fn !== 'function') {
175+
throw new Error(ERRORS.RETRY_NEEDS_A_FUNCTION);
176+
}
177+
178+
if (typeof options !== 'object') {
179+
throw new Error(ERRORS.RETRY_NEEDS_OPTIONS_TO_BE_OBJECT);
180+
}
181+
182+
// Get options with defaults
183+
const {
184+
delay = 1000,
185+
retries = 3,
186+
logChannel = 'retry()',
187+
logNamespace = 'frontvue',
188+
} = options;
189+
190+
// Options validation
191+
if (delay === 0) {
192+
throw new Error(ERRORS.RETRY_DELAY_CANNOT_BE_ZERO);
193+
} else if (retries === 0) {
194+
throw new Error(ERRORS.RETRY_RETRIES_CANNOT_BE_ZERO);
195+
}
196+
197+
// Internal retry counter
198+
let count: number = 0;
199+
// Array to store the errors
200+
const errors: Error[] = [];
201+
// Logger instance
202+
const logger: ILogger = Logger(logNamespace)(logChannel);
203+
204+
205+
/**
206+
* Handle error and retry
207+
* @param error Caught error
208+
*/
209+
function errorHandler(error: Error) {
210+
count++;
211+
212+
// If this is the first retry notify the user
213+
if (count === 1) {
214+
logger.warn(`${getFnName(fn)} failed. Retrying...`);
215+
}
216+
217+
// Output a nice console log for each retry
218+
let logMessage = `Retrying ${getFnName(fn)} (retries left: ${retries - count})`;
219+
logMessage += count === retries ? ' Aborting...' : '';
220+
logger.debug(logMessage);
221+
222+
// Storing the error
223+
errors.push(error);
224+
}
225+
226+
// Returning a function for async functionality
227+
return new Promise(async (resolve, reject) => {
228+
// Stay within the maximum number of retries
229+
while (count < retries) {
230+
try {
231+
// Happy path: resolve promise with the function's return value
232+
// Here we're also converting the return value to a promise
233+
// just in case it's not an async function
234+
// We're also catching any errors and re-throwing them to be catched by the errorHandler
235+
return resolve(
236+
await Promise.resolve(fn()).catch(error => { throw error; }),
237+
);
238+
} catch (error) {
239+
errorHandler(error);
240+
}
241+
// Wait a bit until we try to call again
242+
await sleep(delay);
243+
}
244+
245+
// Finally, nothing seems to work, reject the promise with the last error
246+
return reject(errors[errors.length - 1]);
247+
});
248+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy