Skip to content

Commit

Permalink
feat(retry): Added sync/async retry() helper
Browse files Browse the repository at this point in the history
  • Loading branch information
grantila committed May 8, 2019
1 parent c24d865 commit 5f14b2e
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 0 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This library is written in TypeScript but is exposed as ES7 (if imported as `alr
* [each](#each)
* [some](#some)
* [once](#once)
* [retry](#retry)
* [defer](#defer)
* [reflect](#reflect)
* [inspect](#inspect)
Expand Down Expand Up @@ -356,6 +357,41 @@ await once1( ); // Will not invoke myFunction, but await its completion!
```


## retry

The `retry( )` function can be used to call a function and "retry" (call it again) if it threw an exception, or returned a rejected promise.

The `retry( times, fn [, retryable ] )` function takes a number for maximum number of retries as first argument, and the function to call as the second argument. If `times` is 1, it will **retry** once, i.e. potentially calling `fn` two times.

The return value of `retry` is the same as that of `fn` as it will return the result of a *successful* call to `fn( )`.

The function is transparently handling callback functions (`fn`) returning *values* or *promises*.

The third and optional argument is a predicate function taking the error thrown/rejected from `fn`. It should return `true` if the error is *retryable*, and `false` if the error is not retryable and should propagate out of `retry` immediately.

Synchronous example:

```ts
function tryOpenFileSync( ) { /* ... */ } // Might throw

// Only retry ENOENT errors
const fd = retry(
Infinity,
tryOpenFileSync,
err => err.code === 'ENOENT'
);
```

Asynchronous example:

```ts
async function sendMessage( ) { /* ... */ } // Might return a rejected promise

// Try sending 3 times. NOTE: await
const anything = await retry( 3, sendMessage );
```


## defer

The `defer` function template returns an object containing both a promise and its resolve/reject functions. This is generally an anti-pattern, and `new Promise( ... )` should be preferred, but this is sometimes necessary (or at least very useful).
Expand Down
65 changes: 65 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default {
props,
reduce,
rethrow,
retry,
some,
specific,
tap,
Expand Down Expand Up @@ -530,6 +531,70 @@ function onceDynamic( ): OnceRunner
}


export function retry< R >(
times: number,
fn: ( ) => R,
retryable: ( err: Error ) => boolean = ( ) => true
)
: R
{
type I = PromiseElement< R >;

const retryAsync = ( promise: Promise< I > ): Promise< I > =>
promise
.catch( ( err: Error ) =>
{
if ( --times < 0 || !retryable( err ) )
throw err;

return retryAsync( < any >fn( ) );
} );

const retrySync = ( _err: Error ): R =>
{
while ( --times >= 0 )
{
try
{
return < R >fn( );
}
catch ( err )
{
if ( !retryable( err ) )
throw err;

_err = err;
}
}

throw _err;
};

try
{
const ret = fn( );

if (
ret &&
typeof ret === "object" &&
typeof ( < any >ret ).then === "function"
)
{
return < any >retryAsync( < any >ret );
}

return < R >ret;
}
catch ( err )
{
if ( !retryable( err ) )
throw err;

return retrySync( err );
}
}


export interface Deferred< T >
{
resolve: ( t: T | PromiseLike< T > ) => void;
Expand Down
136 changes: 136 additions & 0 deletions test.in/already/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
reduce,
reflect,
rethrow,
retry,
some,
specific,
tap,
Expand All @@ -26,6 +27,7 @@ import {
} from "../../";

const fooError = "foo error";
const testError = new Error( fooError );
const fooValue = 4711;
const barValue = 17;

Expand Down Expand Up @@ -1216,6 +1218,140 @@ describe( "once", ( ) =>
} );


describe( "retry", ( ) =>
{
const allTimes = [ 0, 1, 2, 3 ];
const times = ( < Array< Array< number > > >[ ] ).concat(
...allTimes.map( times1 =>
allTimes.map( times2 => [ times1, times2 ] )
)
);

describe( "sync", ( ) =>
{
const throwFirst = < R >( times: number, returnValue: R ) =>
{
return ( ): R =>
{
if ( --times < 0 )
return returnValue;
throw testError;
};
};

times.forEach( ( [ returnAfter, retryTimes ] ) =>
{
const shouldThrow = returnAfter > retryTimes && returnAfter > 0;

const msg = `should ${shouldThrow ? "" : "not "}throw ` +
`after ${retryTimes} retries ` +
`when returning after ${returnAfter} times`;

it( msg, ( ) =>
{
if ( shouldThrow )
{
const thrower = throwFirst( returnAfter, 42 );

expect( ( ) => retry( retryTimes, thrower ) ).to.throw( testError );
}
else
{
const thrower = throwFirst( returnAfter, 42 );

expect( retry( retryTimes, thrower ) ).to.equal( 42 );
}
} );
} );

it( "should rethrow on immediately false predicate", ( ) =>
{
const thrower = throwFirst( 5, 42 );

expect( ( ) => retry( 10, thrower, ( ) => false ) )
.to.throw( testError );
} );

it( "should rethrow on eventually false predicate", ( ) =>
{
const thrower = throwFirst( 5, 42 );

let i = 2;

expect( ( ) => retry( 10, thrower, ( ) => --i > 0 ) )
.to.throw( testError );
} );
} );

describe( "async", ( ) =>
{
const throwFirst = < R >( times: number, returnValue: R ) =>
{
return async ( ): Promise< R > =>
{
await delay( 1 );

if ( --times < 0 )
return returnValue;
throw testError;
};
};

times.forEach( ( [ returnAfter, retryTimes ] ) =>
{
const shouldThrow = returnAfter > retryTimes && returnAfter > 0;

const msg = `should ${shouldThrow ? "" : "not "}throw ` +
`after ${retryTimes} retries ` +
`when returning after ${returnAfter} times`;

it( msg, async ( ) =>
{
if ( shouldThrow )
{
const thrower = throwFirst( returnAfter, 42 );

const result =
await reflect( retry( retryTimes, thrower ) );
expect( result.isRejected ).to.be.true;
expect( result.error ).to.equal( testError );
}
else
{
const thrower = throwFirst( returnAfter, 42 );

expect( await retry( retryTimes, thrower ) ).to.equal( 42 );
}
} );
} );

it( "should rethrow on immediately false predicate", async ( ) =>
{
const thrower = throwFirst( 5, 42 );

const result =
await reflect( retry( 10, thrower, ( ) => false ) );

expect( result.isRejected ).to.be.true;
expect( result.error ).to.equal( testError );
} );

it( "should rethrow on eventually false predicate", async ( ) =>
{
const thrower = throwFirst( 5, 42 );

let i = 2;

const result =
await reflect( retry( 10, thrower, ( ) => --i > 0 ) );

expect( result.isRejected ).to.be.true;
expect( result.error ).to.equal( testError );
} );
} );
} );


describe( "defer", ( ) =>
{
it( "should work with undefined and no resolve argument", async ( ) =>
Expand Down

0 comments on commit 5f14b2e

Please sign in to comment.