Skip to the solution
MediumFrontend9 min read

How do you handle errors and retries globally with interceptors?

531 views

The problem

Question presented to candidate: "Our API sometimes returns 503 for a second or two during deploys, and every component has its own copy-pasted catchError. Design a global approach with interceptors: what gets retried, how users see errors, and how you would prove it works."

What a strong answer should cover:

  • Two interceptors with separate jobs: a retry interceptor (inner) and an error interceptor (outer) that maps the final failure to one user-facing message.
  • Retry only transient failures (status 0 and 5xx) and only idempotent methods (GET, HEAD), with a cap and exponential backoff via retry({ count, delay }).
  • Fail fast on 4xx: retrying a 401 or 404 just delays the inevitable.
  • Order matters: the error interceptor must wrap the retry interceptor, or it fires once per attempt.
  • Rethrow after handling (throwError(() => err)) so callers can still react; do not swallow errors into fake successes.
  • Retrying works by resubscribing to next(req); work done eagerly in an inner interceptor does not repeat.
  • Proving it: provideHttpClientTesting() + HttpTestingController to flush 503, 502, 200 and count requests.

Clarifying questions expected:

  • "Which endpoints are safe to retry: are any POSTs idempotent with an idempotency key?"
  • "How should errors reach the user: a toast, a banner, a redirect to login on 401?"

Code / implementation expected: Yes β€” an error interceptor plus a retry interceptor with backoff, and a test with HttpTestingController.

  • interceptor
  • retry

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 10 sections

Read the code

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";

// One place the whole app reads errors from (a toast, a banner...).
@Injectable({ providedIn: "root" })
export class ErrorStore {
  last = signal("none");
}

// OUTER: sees only the final outcome, after all retries are spent.
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const errors = inject(ErrorStore); // read the injector now, not inside catchError
  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); // rethrow so the caller can still react
    }),
  );
};

// INNER: retries only what is worth retrying, with exponential backoff.
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== "GET") return next(req); // never auto-retry non-idempotent calls
  return next(req).pipe(
    retry({
      count: 2,
      delay: (err: HttpErrorResponse, attempt) => {
        const transient = err.status === 0 || err.status >= 500;
        if (!transient) return throwError(() => err); // 401/404: fail now
        return timer(10 * 2 ** attempt); // 20ms, 40ms
      },
    }),
  );
};

// Fake server: counts attempts per URL. /flaky fails twice, /down always fails.
// defer(): do the work on each subscribe, like a real backend, so retry() re-sends.
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";

// The interceptor under test: retry 5xx up to twice, fail fast on anything else.
export const retryInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    retry({
      count: 2,
      delay: (err: HttpErrorResponse) => (err.status >= 500 ? timer(0) : throwError(() => err)),
    }),
  );

// Real interceptors + a testing backend: every request waits for the test to flush it.
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}`),
    });

    // Each retry is a brand-new request the backend must answer.
    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(); // throws if any request is still open
    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}`);
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 50 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

What is the difference between component styling using :host, :host-context, and ::ng-deep?

Continue