AngularGlobal error interceptor wrapping a retry-with-backoff interceptor
import { ApplicationConfig, Component, Injectable, inject, signal } from "@angular/core";
import {
HttpClient, HttpErrorResponse, HttpInterceptorFn, HttpResponse, provideHttpClient, withInterceptors,
} from "@angular/common/http";
import { catchError, defer, of, retry, throwError, timer } from "rxjs";
@Injectable({ providedIn: "root" })
export class ErrorStore {
last = signal("none");
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const errors = inject(ErrorStore);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
const msg = err.status === 401 ? "Please sign in again"
: err.status >= 500 || err.status === 0 ? "Server unavailable, try later"
: `Request failed (${err.status})`;
errors.last.set(`${msg} [${req.url}]`);
return throwError(() => err);
}),
);
};
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== "GET") return next(req);
return next(req).pipe(
retry({
count: 2,
delay: (err: HttpErrorResponse, attempt) => {
const transient = err.status === 0 || err.status >= 500;
if (!transient) return throwError(() => err);
return timer(10 * 2 ** attempt);
},
}),
);
};
export const attempts = new Map<string, number>();
const fakeBackend: HttpInterceptorFn = (req) => defer(() => {
const n = (attempts.get(req.url) ?? 0) + 1;
attempts.set(req.url, n);
const fail = (status: number) => throwError(() => new HttpErrorResponse({ status, url: req.url }));
if (req.url === "/api/flaky" && n <= 2) return fail(503);
if (req.url === "/api/down") return fail(503);
if (req.url === "/api/secure") return fail(401);
return of(new HttpResponse({ status: 200, body: "ok" }));
});
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([errorInterceptor, retryInterceptor, fakeBackend]))],
};
@Component({
selector: "app-root",
template: `
@for (url of urls; track url) {
<button (click)="call(url)">{{ url }}</button>
}
<p id="result">{{ result() }}</p>
<p id="banner">banner: {{ errors.last() }}</p>
`,
})
export class AppComponent {
private http = inject(HttpClient);
errors = inject(ErrorStore);
urls = ["/api/flaky", "/api/down", "/api/secure"];
result = signal("");
call(url: string) {
this.http.get(url, { responseType: "text" }).subscribe({
next: (body) => this.result.set(`${url}: ${body} after ${attempts.get(url)} attempt(s)`),
error: (e: HttpErrorResponse) =>
this.result.set(`${url}: error ${e.status} after ${attempts.get(url)} attempt(s)`),
});
}
}
AngularTesting the retry policy with provideHttpClientTesting() and HttpTestingController
import { ApplicationConfig, Component, inject } from "@angular/core";
import {
HttpClient, HttpErrorResponse, HttpInterceptorFn, provideHttpClient, withInterceptors,
} from "@angular/common/http";
import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing";
import { retry, throwError, timer } from "rxjs";
export const retryInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
retry({
count: 2,
delay: (err: HttpErrorResponse) => (err.status >= 500 ? timer(0) : throwError(() => err)),
}),
);
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([retryInterceptor])), provideHttpClientTesting()],
};
@Component({ selector: "app-root", template: `retry test harness` })
export class AppComponent {
private http = inject(HttpClient);
private backend = inject(HttpTestingController);
async runTest() {
const tick = () => new Promise((r) => setTimeout(r, 5));
let outcome = "(pending)";
this.http.get("/api/report").subscribe({
next: (b) => (outcome = `next ${JSON.stringify(b)}`),
error: (e) => (outcome = `error ${e.status}`),
});
for (const status of [503, 502, 200]) {
const req = this.backend.expectOne("/api/report");
if (status === 200) req.flush({ ok: true });
else req.flush("down", { status, statusText: "Bad Gateway" });
console.log(`flushed ${status}, outcome so far: ${outcome}`);
await tick();
}
this.backend.verify();
console.log("verify(): no outstanding requests");
this.http.get("/api/secret").subscribe({ error: (e) => (outcome = `error ${e.status}`) });
this.backend.expectOne("/api/secret").flush("no", { status: 401, statusText: "Unauthorized" });
await tick();
console.log(`401 outcome: ${outcome} | retries sent: ${this.backend.match("/api/secret").length}`);
}
}