Skip to content

Commit 5f69795

Browse files
authored
feat(retry): Now supports configurable delay as a named argument (#6421)
* feat(retry): Add configurable delay - Adds a `delay` configuration that allows the user to create simpler exponential backoff, simple retry delays, and/or other functionality. * chore: Address comments * chore: revert breaking change and use resetOnSuccess
1 parent 69f5bfa commit 5f69795

File tree

2 files changed

+313
-11
lines changed

2 files changed

+313
-11
lines changed

spec/operators/retry-spec.ts

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @prettier */
22
import { expect } from 'chai';
33
import { retry, map, take, mergeMap, concat, multicast, refCount } from 'rxjs/operators';
4-
import { Observable, Observer, defer, range, of, throwError, Subject } from 'rxjs';
4+
import { Observable, Observer, defer, range, of, throwError, Subject, timer, EMPTY } from 'rxjs';
55
import { TestScheduler } from 'rxjs/testing';
66
import { observableMatcher } from '../helpers/observableMatcher';
77

@@ -411,4 +411,248 @@ describe('retry', () => {
411411
expectSubscriptions(source.subscriptions).toBe(subs);
412412
});
413413
});
414+
415+
describe('with delay config', () => {
416+
describe('of a number', () => {
417+
it('should delay the retry by a specified amount of time', () => {
418+
rxTest.run(({ cold, time, expectSubscriptions, expectObservable }) => {
419+
const source = cold('---a---b---#');
420+
const t = time(' ----|');
421+
const subs = [
422+
//
423+
' ^----------!',
424+
' ---------------^----------!',
425+
' ------------------------------^----------!',
426+
' ---------------------------------------------^----!',
427+
];
428+
const unsub = ' ^-------------------------------------------------!';
429+
const expected = ' ---a---b----------a---b----------a---b----------a--';
430+
const result = source.pipe(
431+
retry({
432+
delay: t,
433+
})
434+
);
435+
expectObservable(result, unsub).toBe(expected);
436+
expectSubscriptions(source.subscriptions).toBe(subs);
437+
});
438+
});
439+
440+
it('should act like a normal retry if delay is set to 0', () => {
441+
rxTest.run(({ cold, expectSubscriptions, expectObservable }) => {
442+
const source = cold('---a---b---#');
443+
const subs = [
444+
//
445+
' ^----------!',
446+
' -----------^----------!',
447+
' ----------------------^----------!',
448+
' ---------------------------------^----!',
449+
];
450+
const unsub = ' ^-------------------------------------!';
451+
const expected = ' ---a---b------a---b------a---b------a--';
452+
const result = source.pipe(
453+
retry({
454+
delay: 0,
455+
})
456+
);
457+
expectObservable(result, unsub).toBe(expected);
458+
expectSubscriptions(source.subscriptions).toBe(subs);
459+
});
460+
});
461+
462+
it('should act like a normal retry if delay is less than 0', () => {
463+
rxTest.run(({ cold, expectSubscriptions, expectObservable }) => {
464+
const source = cold('---a---b---#');
465+
const subs = [
466+
//
467+
' ^----------!',
468+
' -----------^----------!',
469+
' ----------------------^----------!',
470+
' ---------------------------------^----!',
471+
];
472+
const unsub = ' ^-------------------------------------!';
473+
const expected = ' ---a---b------a---b------a---b------a--';
474+
const result = source.pipe(
475+
retry({
476+
delay: -100,
477+
})
478+
);
479+
expectObservable(result, unsub).toBe(expected);
480+
expectSubscriptions(source.subscriptions).toBe(subs);
481+
});
482+
});
483+
484+
it('should honor count as the max retries', () => {
485+
rxTest.run(({ cold, time, expectSubscriptions, expectObservable }) => {
486+
const source = cold('---a---b---#');
487+
const t = time(' ----|');
488+
const subs = [
489+
//
490+
' ^----------!',
491+
' ---------------^----------!',
492+
' ------------------------------^----------!',
493+
];
494+
const expected = ' ---a---b----------a---b----------a---b---#';
495+
const result = source.pipe(
496+
retry({
497+
count: 2,
498+
delay: t,
499+
})
500+
);
501+
expectObservable(result).toBe(expected);
502+
expectSubscriptions(source.subscriptions).toBe(subs);
503+
});
504+
});
505+
});
506+
507+
describe('of a function', () => {
508+
it('should delay the retry with a function that returns a notifier', () => {
509+
rxTest.run(({ cold, expectSubscriptions, expectObservable }) => {
510+
const source = cold('---a---b---#');
511+
const subs = [
512+
//
513+
' ^----------!',
514+
' ------------^----------!',
515+
' -------------------------^----------!',
516+
' ---------------------------------------^----!',
517+
];
518+
const unsub = ' ^-------------------------------------------!';
519+
const expected = ' ---a---b-------a---b--------a---b---------a--';
520+
const result = source.pipe(
521+
retry({
522+
delay: (_err, retryCount) => {
523+
// retryCount will be 1, 2, 3, etc.
524+
return timer(retryCount);
525+
},
526+
})
527+
);
528+
expectObservable(result, unsub).toBe(expected);
529+
expectSubscriptions(source.subscriptions).toBe(subs);
530+
});
531+
});
532+
533+
it('should delay the retry with a function that returns a hot observable', () => {
534+
rxTest.run(({ cold, hot, expectSubscriptions, expectObservable }) => {
535+
const source = cold(' ---a---b---#');
536+
const notifier = hot('--------------x----------------x----------------x------');
537+
const subs = [
538+
//
539+
' ^----------!',
540+
' --------------^----------!',
541+
' -------------------------------^----------!',
542+
];
543+
const notifierSubs = [
544+
//
545+
' -----------^--!',
546+
' -------------------------^-----!',
547+
' ------------------------------------------^-!',
548+
];
549+
const unsub = ' ^-------------------------------------------!';
550+
const expected = ' ---a---b---------a---b------------a---b------';
551+
const result = source.pipe(
552+
retry({
553+
delay: () => notifier,
554+
})
555+
);
556+
expectObservable(result, unsub).toBe(expected);
557+
expectSubscriptions(source.subscriptions).toBe(subs);
558+
expectSubscriptions(notifier.subscriptions).toBe(notifierSubs);
559+
});
560+
});
561+
562+
it('should complete if the notifier completes', () => {
563+
rxTest.run(({ cold, expectSubscriptions, expectObservable }) => {
564+
const source = cold('---a---b---#');
565+
const subs = [
566+
//
567+
' ^----------!',
568+
' ------------^----------!',
569+
' -------------------------^----------!',
570+
' ------------------------------------!',
571+
];
572+
const expected = ' ---a---b-------a---b--------a---b---|';
573+
const result = source.pipe(
574+
retry({
575+
delay: (_err, retryCount) => {
576+
return retryCount <= 2 ? timer(retryCount) : EMPTY;
577+
},
578+
})
579+
);
580+
expectObservable(result).toBe(expected);
581+
expectSubscriptions(source.subscriptions).toBe(subs);
582+
});
583+
});
584+
585+
it('should error if the notifier errors', () => {
586+
rxTest.run(({ cold, expectSubscriptions, expectObservable }) => {
587+
const source = cold('---a---b---#');
588+
const subs = [
589+
//
590+
' ^----------!',
591+
' ------------^----------!',
592+
' -------------------------^----------!',
593+
' ------------------------------------!',
594+
];
595+
const expected = ' ---a---b-------a---b--------a---b---#';
596+
const result = source.pipe(
597+
retry({
598+
delay: (_err, retryCount) => {
599+
return retryCount <= 2 ? timer(retryCount) : throwError(() => new Error('blah'));
600+
},
601+
})
602+
);
603+
expectObservable(result).toBe(expected, undefined, new Error('blah'));
604+
expectSubscriptions(source.subscriptions).toBe(subs);
605+
});
606+
});
607+
608+
it('should error if the delay function throws', () => {
609+
rxTest.run(({ cold, expectSubscriptions, expectObservable }) => {
610+
const source = cold('---a---b---#');
611+
const subs = [
612+
//
613+
' ^----------!',
614+
' ------------^----------!',
615+
' -------------------------^----------!',
616+
' ------------------------------------!',
617+
];
618+
const expected = ' ---a---b-------a---b--------a---b---#';
619+
const result = source.pipe(
620+
retry({
621+
delay: (_err, retryCount) => {
622+
if (retryCount <= 2) {
623+
return timer(retryCount);
624+
} else {
625+
throw new Error('blah');
626+
}
627+
},
628+
})
629+
);
630+
expectObservable(result).toBe(expected, undefined, new Error('blah'));
631+
expectSubscriptions(source.subscriptions).toBe(subs);
632+
});
633+
});
634+
635+
it('should be usable for exponential backoff', () => {
636+
rxTest.run(({ cold, expectObservable, expectSubscriptions }) => {
637+
const source = cold('---a---#');
638+
const subs = [
639+
//
640+
' ^------!',
641+
' ---------^------!',
642+
' --------------------^------!',
643+
' -----------------------------------^------!',
644+
];
645+
const expected = ' ---a--------a----------a--------------a---#';
646+
const result = source.pipe(
647+
retry({
648+
count: 3,
649+
delay: (_err, retryCount) => timer(2 ** retryCount),
650+
})
651+
);
652+
expectObservable(result).toBe(expected);
653+
expectSubscriptions(source.subscriptions).toBe(subs);
654+
});
655+
});
656+
});
657+
});
414658
});

src/internal/operators/retry.ts

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1-
import { MonoTypeOperatorFunction } from '../types';
1+
import { MonoTypeOperatorFunction, ObservableInput } from '../types';
22
import { operate } from '../util/lift';
33
import { Subscription } from '../Subscription';
44
import { OperatorSubscriber } from './OperatorSubscriber';
55
import { identity } from '../util/identity';
6+
import { timer } from '../observable/timer';
7+
import { innerFrom } from '../observable/from';
68

79
export interface RetryConfig {
8-
count: number;
10+
/**
11+
* The maximum number of times to retry.
12+
*/
13+
count?: number;
14+
/**
15+
* The number of milliseconds to delay before retrying, OR a function to
16+
* return a notifier for delaying. If a function is returned, that function should
17+
* return a notifier that, when it emits will retry the source. If the notifier
18+
* completes _without_ emitting, the resulting observable will complete without error,
19+
* if the notifier errors, the error will be pushed to the result.
20+
*/
21+
delay?: number | ((error: any, retryCount: number) => ObservableInput<any>);
22+
/**
23+
* Whether or not to reset the retry counter when the retried subscription
24+
* emits its first value.
25+
*/
926
resetOnSuccess?: boolean;
1027
}
1128

@@ -50,13 +67,22 @@ export interface RetryConfig {
5067
* // "Error!: Retried 2 times then quit!"
5168
* ```
5269
*
53-
* @param {number} count - Number of retry attempts before failing.
54-
* @param {boolean} resetOnSuccess - When set to `true` every successful emission will reset the error count
70+
* @param count - Number of retry attempts before failing.
71+
* @param resetOnSuccess - When set to `true` every successful emission will reset the error count
5572
* @return A function that returns an Observable that will resubscribe to the
5673
* source stream when the source stream errors, at most `count` times.
5774
*/
5875
export function retry<T>(count?: number): MonoTypeOperatorFunction<T>;
76+
77+
/**
78+
* Returns an observable that mirrors the source observable unless it errors. If it errors, the source observable
79+
* will be resubscribed to (or "retried") based on the configuration passed here. See documentation
80+
* for {@link RetryConfig} for more details.
81+
*
82+
* @param config - The retry configuration
83+
*/
5984
export function retry<T>(config: RetryConfig): MonoTypeOperatorFunction<T>;
85+
6086
export function retry<T>(configOrCount: number | RetryConfig = Infinity): MonoTypeOperatorFunction<T> {
6187
let config: RetryConfig;
6288
if (configOrCount && typeof configOrCount === 'object') {
@@ -66,7 +92,7 @@ export function retry<T>(configOrCount: number | RetryConfig = Infinity): MonoTy
6692
count: configOrCount,
6793
};
6894
}
69-
const { count, resetOnSuccess = false } = config;
95+
const { count = Infinity, delay, resetOnSuccess: resetOnSuccess = false } = config;
7096

7197
return count <= 0
7298
? identity
@@ -79,6 +105,7 @@ export function retry<T>(configOrCount: number | RetryConfig = Infinity): MonoTy
79105
new OperatorSubscriber(
80106
subscriber,
81107
(value) => {
108+
// If we're resetting on success
82109
if (resetOnSuccess) {
83110
soFar = 0;
84111
}
@@ -88,14 +115,45 @@ export function retry<T>(configOrCount: number | RetryConfig = Infinity): MonoTy
88115
undefined,
89116
(err) => {
90117
if (soFar++ < count) {
91-
if (innerSub) {
92-
innerSub.unsubscribe();
93-
innerSub = null;
94-
subscribeForRetry();
118+
// We are still under our retry count
119+
const resub = () => {
120+
if (innerSub) {
121+
innerSub.unsubscribe();
122+
innerSub = null;
123+
subscribeForRetry();
124+
} else {
125+
syncUnsub = true;
126+
}
127+
};
128+
129+
if (delay != null) {
130+
// The user specified a retry delay.
131+
// They gave us a number, use a timer, otherwise, it's a function,
132+
// and we're going to call it to get a notifier.
133+
const notifier = typeof delay === 'number' ? timer(delay) : innerFrom(delay(err, soFar));
134+
const notifierSubscriber = new OperatorSubscriber(
135+
subscriber,
136+
() => {
137+
// After we get the first notification, we
138+
// unsubscribe from the notifer, because we don't want anymore
139+
// and we resubscribe to the source.
140+
notifierSubscriber.unsubscribe();
141+
resub();
142+
},
143+
() => {
144+
// The notifier completed without emitting.
145+
// The author is telling us they want to complete.
146+
subscriber.complete();
147+
}
148+
);
149+
notifier.subscribe(notifierSubscriber);
95150
} else {
96-
syncUnsub = true;
151+
// There was no notifier given. Just resub immediately.
152+
resub();
97153
}
98154
} else {
155+
// We're past our maximum number of retries.
156+
// Just send along the error.
99157
subscriber.error(err);
100158
}
101159
}

0 commit comments

Comments
 (0)