diff --git a/src/index.ts b/src/index.ts index 451fb22f..97755d60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,7 @@ export { export {page} from './ops/page'; export {reduce} from './ops/reduce'; export {repeat} from './ops/repeat'; -export {retry} from './ops/async/retry'; +export {retry} from './ops/retry'; export {skip} from './ops/skip'; export {skipUntil} from './ops/skip-until'; export {skipWhile} from './ops/skip-while'; diff --git a/src/ops/async/retry.ts b/src/ops/retry.ts similarity index 60% rename from src/ops/async/retry.ts rename to src/ops/retry.ts index 9810de2f..8d3bbc75 100644 --- a/src/ops/async/retry.ts +++ b/src/ops/retry.ts @@ -1,18 +1,18 @@ -import {$A, IterationState, Operation} from '../../types'; -import {isPromiseLike} from '../../typeguards'; -import {createOperation, throwOnSync} from '../../utils'; +import {$A, $S, IterationState, Operation} from '../types'; +import {isPromiseLike} from '../typeguards'; +import {createOperation} from '../utils'; /** - * When an asynchronous iterable rejects, it retries getting the value specified number of times. + * When an iterable throws or rejects, it retries getting the value specified number of times. * - * Note that retries deplete values prior the operator that threw the error, and so it is often + * Note that retries deplete values prior the operator that threw the error, and so it is often * used in combination with operator {@link repeat}. * * ```ts - * import {pipe, toAsync, tap, retry} from 'iter-ops'; + * import {pipe, tap, retry} from 'iter-ops'; * * const i = pipe( - * toAsync([1, 2, 3, 4, 5, 6, 7, 8, 9]), + * [1, 2, 3, 4, 5, 6, 7, 8, 9], * tap(value => { * if (value % 2 === 0) { * throw new Error(`fail-${value}`); // throw for all even numbers @@ -21,24 +21,20 @@ import {createOperation, throwOnSync} from '../../utils'; * retry(1) // retry 1 time * ); * - * for await(const a of i) { - * console.log(a); // 1, 3, 5, 7, 9 - * } + * console.log(...i); //=> 1, 3, 5, 7, 9 * ``` * * Above, we end up with just odd numbers, because we do not provide any {@link repeat} logic, * and as a result, the `retry` simply jumps to the next value on each error. * - * @throws `Error: 'Operator "retry" requires asynchronous pipeline'` when used inside a synchronous pipeline. - * * @see * - {@link repeat} - * @category Async-only + * @category Sync+Async */ export function retry(attempts: number): Operation; /** - * When an asynchronous iterable rejects, the callback is to return the flag, indicating whether + * When an iterable throws or rejects, the callback is to return the flag, indicating whether * we should retry getting the value one more time. * * The callback is only invoked when there is a failure, and it receives: @@ -49,11 +45,9 @@ export function retry(attempts: number): Operation; * Note that retries deplete values prior the operator that threw the error, * and so it is often used in combination with operator {@link repeat}. * - * @throws `Error: 'Operator "retry" requires asynchronous pipeline'` when used inside a synchronous pipeline. - * * @see * - {@link repeat} - * @category Async-only + * @category Sync+Async */ export function retry( cb: ( @@ -64,18 +58,52 @@ export function retry( ): Operation; export function retry(...args: unknown[]) { - return createOperation(throwOnSync('retry'), retryAsync, args); + return createOperation(retrySync, retryAsync, args); +} + +type Retry = + | number + | ((index: number, attempts: number, state: IterationState) => T); + +function retrySync( + iterable: Iterable, + retry: Retry +): Iterable { + return { + [$S](): Iterator { + const i = iterable[$S](); + const state: IterationState = {}; + let index = 0; + const cb = typeof retry === 'function' && retry; + let attempts = 0; + const retriesNumber = !cb && retry > 0 ? retry : 0; + let leftTries = retriesNumber; + return { + next(): IteratorResult { + do { + try { + const a = i.next(); + index++; + attempts = 0; + leftTries = retriesNumber; + return a; + } catch (err) { + const r = cb && cb(index, attempts++, state); + if (r || leftTries--) { + continue; + } + throw err; // out of attempts, re-throw + } + } while (true); + } + }; + } + }; } function retryAsync( iterable: AsyncIterable, - retry: - | number - | (( - index: number, - attempts: number, - state: IterationState - ) => boolean | Promise) + retry: Retry> ): AsyncIterable { return { [$A](): AsyncIterator { diff --git a/test/ops/retry/index.spec.ts b/test/ops/retry/index.spec.ts index b166a053..f4363d8c 100644 --- a/test/ops/retry/index.spec.ts +++ b/test/ops/retry/index.spec.ts @@ -1,5 +1,7 @@ +import sync from './sync'; import async from './async'; describe('retry', () => { + describe('sync', sync); describe('async', async); }); diff --git a/test/ops/retry/sync.ts b/test/ops/retry/sync.ts new file mode 100644 index 00000000..14c8640a --- /dev/null +++ b/test/ops/retry/sync.ts @@ -0,0 +1,54 @@ +import {expect} from '../../header'; +import {pipe, retry, tap} from '../../../src'; + +export default () => { + it('must not retry on 0 attempts', () => { + let count = 0; + const i = pipe( + [1, 2, 3], + tap(() => { + if (!count++) { + throw 'ops!'; // throw only once + } + }), + retry(0) + ); + expect(() => { + [...i]; + }).to.throw('ops!'); + }); + it('must retry the number of attempts', () => { + let count = 0; + const i = pipe( + [1, 2, 3, 4, 5], + tap(() => { + if (count++ < 3) { + throw 'ops!'; // throw only once + } + }), + retry(3) + ); + expect([...i]).to.eql([4, 5]); + }); + it('must retry on callback result', () => { + let count = 0; + const indexes: Array = [], + attempts: Array = []; + const i = pipe( + [1, 2, 3, 4, 5], + tap(() => { + if (count++ < 3) { + throw 'ops!'; // throw 3 times + } + }), + retry((idx, att) => { + indexes.push(idx); + attempts.push(att); + return true; + }) + ); + expect([...i]).to.eql([4, 5]); + expect(indexes).to.eql([0, 0, 0]); + expect(attempts).to.eql([0, 1, 2]); + }); +};