I have the same need in my angular app, so I created a pipeable operator called retryWithBackoff. It uses exponential backoff, so the time between retries looks like:
delayMs * Math.pow(2, retries)
It can be used very simply:
getDiscussionList(): Observable<DiscussionListModel[]> | Observable<never> {
return this.httpClient
.get<DiscussionListModel[]>('/api/discussions').pipe(
retryWithBackoff()
);
Here is the operator:
export function retryWithBackoff<T>(
delayMs: number = 1000,
maxRetries: number = 5,
maxTime: number = 12000
): MonoTypeOperatorFunction<T> {
return (source: Observable<T>) => {
const currentMs = new Date().getTime();
return source.pipe(
timeout(maxTime),
retryWhen(errors => {
let retries = 0;
return errors.pipe(
mergeMap(next => {
// If we caught TimeoutError we must rethrow because retryWhen would retry
// if request failed due to timeout.
// The timeout(maxTime) isn't reached because a new request is sent
// therefore we have to compare against currentMs
if ((next instanceof TimeoutError)
|| (new Date()).getTime() >= currentMs + maxTime) {
return throwError(new HttpTimeoutError());
}
retries++;
if (retries >= maxRetries) {
return throwError(new HttpMaxRetriesError());
}
return timer(delayMs * Math.pow(2, retries));
}),
);
})
);
};
}
It will timeout in 12 seconds, regardless of how many attempts have been made. The two exceptions it can throw are just an empty custom exception class:
export class HttpEstablishError {}
export class HttpMaxRetriesError extends HttpEstablishError {}
export class HttpTimeoutError extends HttpEstablishError {}
I also have some incomplete jasmine tests for it:
describe('Utilities', () => {
describe('retryWithBackoff should', () => {
it('return success observable after failing max-1 times', (done) => {
const source = (count, maxRetries) => {
return defer(() => {
if (count <= maxRetries) {
count++;
return throwError(true);
} else {
return of(true);
}
});
};
source(1, 5 - 1).pipe(retryWithBackoff(1, 5)).subscribe(
(value) => {
expect(value).toBe(true);
},
() => {
fail();
done();
},
() => {
done();
}
);
});
it('raise HttpTimeoutError if maxTime is reached', (done) => {
const maxTime = 1000;
const source = new Subject<any>();
source.pipe(retryWithBackoff(1000, 5, maxTime)).subscribe(
() => {
fail('should not happen');
},
(err) => {
expect(err).toBeInstanceOf(HttpTimeoutError);
done();
}
);
});
});
it('raise HttpMaxRetriesError is maxRetries is reached', (done) => {
const source = (count, maxRetries) => {
return defer(() => {
if (count <= maxRetries) {
count++;
return throwError(true);
} else {
return of(true);
}
});
};
source(1, 5 + 1).pipe(retryWithBackoff(1, 5)).subscribe(
() => {
},
(err) => {
expect(err).toBeInstanceOf(HttpMaxRetriesError);
done();
},
);
});
});