Skip to content

Instantly share code, notes, and snippets.

@imran3
Last active July 24, 2024 17:53
Show Gist options
  • Save imran3/b5dff3e89b7689a7f5ac534d7bd4cacf to your computer and use it in GitHub Desktop.
Save imran3/b5dff3e89b7689a7f5ac534d7bd4cacf to your computer and use it in GitHub Desktop.
Retry http errors mechanism in Angular
import { HttpClient, HttpErrorResponse, HttpResponse, HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { AppConfig } from '../../services/app-config/app-config.interface';
import { AppConfigService } from '../../services/app-config/app-config.service';
import { MOCK_APP_CONFIG } from '../../services/app-config/mock/get-config.mock';
import { RetryHttpErrorsInterceptor } from './retry-http-errors.interceptor';
describe('RetryHttpErrorsInterceptor', () => {
let interceptor: RetryHttpErrorsInterceptor;
let appConfigService: AppConfigService;
let httpClient: HttpClient;
let httpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
RetryHttpErrorsInterceptor,
{
provide: HTTP_INTERCEPTORS,
useClass: RetryHttpErrorsInterceptor,
multi: true,
},
{
provide: AppConfigService,
useValue: {
getConfig: jest.fn(),
},
},
],
});
appConfigService = TestBed.inject(AppConfigService);
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
});
it('should be created', () => {
interceptor = TestBed.inject(RetryHttpErrorsInterceptor);
expect(interceptor).toBeTruthy();
});
describe('Using AppConfig', () => {
let mockAppConfig: AppConfig;
beforeEach(() => {
mockAppConfig = {
...MOCK_APP_CONFIG,
maximumRetries: 5,
};
appConfigService.getConfig = jasmine.createSpy().and.returnValue(mockAppConfig);
});
it('should retry configured maximum retries times', fakeAsync(() => {
const requesturl = '/foo/bar';
const errorMsg = 'Something went wrong';
const errorResponse: HttpErrorResponse = new HttpErrorResponse({
status: 502,
statusText: 'Some status text for 502',
});
httpClient.get(requesturl).subscribe(
_ => fail('should have failed with status code 502'),
(error: HttpErrorResponse) => {
expect(error.error).toEqual(errorMsg);
expect(error.status).toEqual(errorResponse.status);
expect(error.statusText).toEqual(errorResponse.statusText);
},
);
// simulate failed responses
for (let i = 0; i < mockAppConfig.maximumRetries + 1; i++) {
const req = httpTestingController.expectOne(requesturl);
req.flush(errorMsg, errorResponse);
tick((i + 1) * mockAppConfig.retryDelay);
}
}));
it('should retry 2 time and get valid response on last retry', fakeAsync(() => {
const requesturl = '/foo/bar';
const errorMsg = 'Something went wrong';
const errorResponse: HttpErrorResponse = new HttpErrorResponse({
status: 502,
statusText: 'Some status text for 502',
});
const successResponse = { foo: 'bar' };
httpClient.get(requesturl).subscribe(
(response: HttpResponse<any>) => {
expect(response).toEqual(successResponse);
},
_ => fail('Should have receive success response on last retry'),
);
// simulate failed responses
for (let i = 0; i < mockAppConfig.maximumRetries - 2; i++) {
const req = httpTestingController.expectOne(requesturl);
req.flush(errorMsg, errorResponse);
tick((i + 1) * mockAppConfig.retryDelay);
}
// simulate success response
const req = httpTestingController.expectOne(requesturl);
req.flush(successResponse);
tick(mockAppConfig.retryDelay);
}));
it('should not retry success response', fakeAsync(() => {
const requesturl = '/foo/bar';
const successResponse = { foo: 'bar' };
httpClient.get(requesturl).subscribe(
(response: HttpResponse<any>) => {
expect(response).toEqual(successResponse);
},
_ => fail('Should have received success response'),
);
// simulate success response
const req = httpTestingController.expectOne(requesturl);
req.flush(successResponse);
tick(mockAppConfig.retryDelay);
}));
it('should no retry client error (status code < 500)', fakeAsync(() => {
const requesturl = '/foo/bar';
const errorMsg = 'Something went wrong';
const errorResponse: HttpErrorResponse = new HttpErrorResponse({
status: 404,
statusText: 'Some status text for 404',
});
httpClient.get(requesturl).subscribe(
_ => fail('should have failed with status code 404'),
(error: HttpErrorResponse) => {
expect(error.error).toEqual(errorMsg);
expect(error.status).toEqual(errorResponse.status);
expect(error.statusText).toEqual(errorResponse.statusText);
},
);
const req = httpTestingController.expectOne(requesturl);
req.flush(errorMsg, errorResponse);
tick(mockAppConfig.retryDelay);
}));
afterEach(() => {
// verify that there are no outstanding request after each test run
httpTestingController.verify();
});
});
describe('Using default config', () => {
it('should retry default maximum retries times', fakeAsync(() => {
appConfigService.getConfig = jasmine.createSpy().and.returnValue(null);
const requesturl = '/foo/bar';
const errorMsg = 'Something went wrong';
const errorResponse: HttpErrorResponse = new HttpErrorResponse({
status: 501,
statusText: 'Some status text for 501',
});
httpClient.get(requesturl).subscribe(
_ => fail('should have failed with status code 501'),
(error: HttpErrorResponse) => {
expect(error.error).toEqual(errorMsg);
expect(error.status).toEqual(errorResponse.status);
expect(error.statusText).toEqual(errorResponse.statusText);
},
);
for (let i = 0; i < 3; i++) {
const req = httpTestingController.expectOne(requesturl);
req.flush(errorMsg, errorResponse);
tick((i + 1) * 200);
}
}));
afterEach(() => {
httpTestingController.verify();
});
});
});
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { retryWhen, concatMap, delay } from 'rxjs/operators';
import { iif, throwError, of, Observable } from 'rxjs';
import { AppConfigService } from '../../services/app-config/app-config.service'; // dynamic config service
export interface RetryRequestOptions {
maximumRetries: number;
retryDelay: number;
}
@Injectable()
export class RetryHttpErrorsInterceptor implements HttpInterceptor {
// default retry options
private retryRequestOptions: RetryRequestOptions = {
maximumRetries: 2,
retryDelay: 200, //milliseconds
};
private retryOptionConfigured = false;
constructor(private appConfigService: AppConfigService) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// first time it run: need to replace default retry config options
if (!this.retryOptionConfigured) {
let appConfig = this.appConfigService.getConfig();
if (appConfig) {
this.retryRequestOptions = {
maximumRetries: appConfig.maximumRetries,
retryDelay: appConfig.retryDelay,
};
this.retryOptionConfigured = true;
}
}
return next.handle(request).pipe(this.retryPipe());
}
retryPipe<T>() {
return retryWhen<T>((errors: Observable<HttpErrorResponse>) =>
errors.pipe(
// Use concat map to keep the errors in order and make sure they
// aren't executed in parallel
concatMap((error: HttpErrorResponse, retryId: number) =>
// Executes a conditional Observable depending on the result
// of the first argument
iif(
() => retryId >= this.retryRequestOptions.maximumRetries || error.status <= 500,
// If the condition is true we throw the error (the last error)
this.returnError(error),
// Otherwise we pipe this back into our stream and delay the retry
this.retryError(error, retryId, this.retryRequestOptions.retryDelay),
),
),
),
);
}
returnError(error: HttpErrorResponse) {
return throwError(error);
}
retryError(error: HttpErrorResponse, retryId: number, retryDelay: number) {
// retry request with increasing delay between each attemp
return of(error).pipe(delay(retryDelay * retryId));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment