This is a simplified version of my Angular 18 error interceptor. It will retry requests that produced an error to make sure it was not a fluke (please don't ask!). This works, however I am running into issues trying to write unit tests for this
let apiService: ApiService;
const genericRetryStrategy = (): ((e: HttpErrorResponse, i: number) => Observable<number>) => {
const maxRetryAttempts = 1;
const scalingDuration = 200;
const excludedStatusCodes = [401, 403, 503];
return (error: HttpErrorResponse, i: number): Observable<number> => {
const retryAttempt = i + 1;
if (retryAttempt > maxRetryAttempts || excludedStatusCodes.some((code) => code === error.status)) {
return throwError(() => error);
}
console.info(`Attempt ${retryAttempt.toString()}: retrying in ${(retryAttempt * scalingDuration).toString()}ms`);
// retry after 1s, 2s, etc...
return timer(retryAttempt * scalingDuration);
};
};
const catchAll$ = (code: string, url: string, error: HttpErrorResponse): Observable<never> => {
console.error('Logging Error', code, error.message);
apiService.logError$(msg, error.error, url).subscribe({
error: () => {
console.error('Unable to log error!');
},
});
return throwError(() => new Error(msg));
};
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
apiService = inject(ApiService);
return next(req).pipe(
retry({ delay: genericRetryStrategy() }),
catchError((error: HttpErrorResponse) => {
return catchAll$(code, req.url, error);
}),
);
};
I am using the jasmine-auto-spies package (which is great!) - but I am not sure exactly how to test this logic. Here is what I have so far
const interceptor: HttpInterceptorFn = (req, next) => TestBed.runInInjectionContext(() => httpErrorInterceptor(req, next));
let httpClient: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
provideHttpClient(withInterceptors([interceptor])),
provideHttpClientTesting(),
{
provide: ApiService,
useValue: createSpyFromClass(ApiService, {
methodsToSpyOn: ['logError$'],
}),
},
],
});
httpClient = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Assert that no other requests were made after each test completes
httpMock.verify();
});
describe('Error Retrying', () => {
it('should retry a 500 error only once with a slight delay', fakeAsync(() => {
httpClient.get('http://server.biz/api/whatever').subscribe({
next: () => {
throw new Error('This should not have been successful!');
},
error: (err) => {
console.log('error 1', err);
},
});
const req1 = httpMock.expectOne('http://server.biz/api/whatever');
req1.flush({}, { status: 500, statusText: 'Server Error 1' });
tick(650);
expect(console.info).toHaveBeenCalledWith(`Attempt 1: retrying in 200ms`);
expect(console.info).toHaveBeenCalledWith(`Attempt 2: retrying in 400ms`);
// ???
}));
it('should NOT retry a 401 error', () => {
httpClient.get('http://example.com/api/whatever').subscribe();
const req1 = httpMock.expectOne('http://server.biz/api/whatever');
req1.flush('message here', { status: 401, statusText: 'Unauthorized' });
// ???
});
it('should NOT retry a 403 error', () => {
// ???
});
it('should NOT retry a 503 error', () => {
// ???
});
});
How do I test this so that I can verify that 2 requests were made, and the 2nd one on a delay?
Turns out my main mistake was assuming that RxJS's retry
count was zero indexed! I had const retryAttempt = i + 1;
which was making the first attempt into 2
- oops!
With that fixed in my code, the test became very straightforward
it('should retry a 500 error only once with a slight delay', fakeAsync(() => {
httpClient.get('http://server.biz/api/whatever').subscribe({
next: () => {
fail('This should not have been successful!');
},
error: (err: HttpErrorResponse) => {
expect(err.status).toEqual(500);
expect(err.statusText).toEqual('Server Error 2');
},
});
const req1 = httpMock.expectOne('http://server.biz/api/whatever');
req1.flush('response body', { status: 500, statusText: `Server Error 1` });
tick(200);
const req2 = httpMock.expectOne('http://server.biz/api/whatever');
req2.flush('response body', { status: 500, statusText: `Server Error 2` });
expect(console.info).toHaveBeenCalledOnceWith(`Attempt 1: retrying in 200ms`);
}));
it('should NOT retry a 401 error', () => {
httpClient.get('http://server.biz/api/whatever').subscribe({
next: () => {
fail('This should not have been successful!');
},
error: (err: HttpErrorResponse) => {
expect(err.status).toEqual(401);
expect(err.statusText).toEqual('Unauthorized');
},
});
const req1 = httpMock.expectOne('http://server.biz/api/whatever');
req1.flush('response body', { status: 401, statusText: `Unauthorized` });
expect(console.info).not.toHaveBeenCalled();
});