In Angular, how would you write interceptors that retry transient HTTP failures and map errors centrally, without retrying unsafe requests or swallowing errors?
answer
- not every failure is transient
- method and status decide
- back off between attempts
- rethrow after you report
basics
~20 sA retry interceptor applies RxJS retry({ count, delay }) to next(req) only for safe methods and transient statuses (0, 502, 503, 504), with backoff. An outer error interceptor reports or maps the final HttpErrorResponse and rethrows it instead of returning an empty stream.
solid answer
~40 sSplit the concerns into two functional interceptors. The **retry interceptor** forwards `next(req)` untouched for non-idempotent methods such as POST, and for GET, HEAD or OPTIONS pipes it through `retry({ count: 2, delay })`, where the `delay` function returns a `timer` with exponential backoff for transient failures — status 0, 502, 503, 504 — and `throwError` for everything else, so a 404 or 400 fails at once. The **error interceptor**, listed before it so it sees only failures that survived the retries, uses `catchError` to report the `HttpErrorResponse` (a toast, monitoring) or map it to an app-level error, then **rethrows**. Returning `EMPTY` would complete the caller's Observable with no value and no error, so the UI would hang or show nothing. A per-request `HttpContextToken` can opt a call out of the toast.
code
ts · 56 linesimport { Injectable, inject } from '@angular/core';
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, retry, throwError, timer } from 'rxjs';
export const SILENT_ERRORS = new HttpContextToken<boolean>(() => false);
const RETRYABLE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const TRANSIENT_STATUSES = new Set([0, 502, 503, 504]);
const isTransient = (error: unknown): boolean =>
error instanceof HttpErrorResponse && TRANSIENT_STATUSES.has(error.status);
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
if (!RETRYABLE_METHODS.has(req.method)) {
return next(req);
}
return next(req).pipe(
retry({
count: 2,
delay: (error: unknown, attempt: number) =>
isTransient(error) ? timer(300 * 2 ** (attempt - 1)) : throwError(() => error),
}),
);
};
@Injectable({ providedIn: 'root' })
export class ErrorNotices {
show(message: string): void {
console.warn(message); // replace with the app's toast service
}
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const notices = inject(ErrorNotices); // inject before any callback runs
return next(req).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
const apiError = new ApiError(error.status, `${req.method} ${req.url} failed with ${error.status}`);
if (!req.context.get(SILENT_ERRORS) && (error.status === 0 || error.status >= 500)) {
notices.show('The service is unavailable. Please try again.');
}
return throwError(() => apiError); // never EMPTY: the caller must still see the failure
}),
);
};go deeper
Know that an interceptor can catch HttpErrorResponse centrally and that errors should be rethrown, not swallowed.
Write retry({ count, delay }) with a delay function that stops on non-transient errors, and order the error interceptor outside the retry one.
Choose which methods and statuses are retryable, add backoff, keep sensitive data out of reports, and support per-request opt-outs through HttpContext.
Align client retry policy with server capacity and idempotency guarantees, so retries from many clients do not amplify an outage.
## Two concerns, two interceptors Retrying and error mapping are both cross-cutting, and interceptors are the natural home for them. Keeping them separate makes each simple and lets the chain order express the policy: ```ts provideHttpClient(withInterceptors([errorInterceptor, retryInterceptor, authInterceptor])) ``` The `errorInterceptor` is outermost, so it only sees a failure after the `retryInterceptor` has given up. ## What is safe to retry A retry re-sends the request. That is only harmless when sending it twice has the same effect as sending it once, and only useful when the failure is likely to go away. | Dimension | Retry | Do not retry | |---|---|---| | HTTP method | `GET`, `HEAD`, `OPTIONS` (safe); `PUT`, `DELETE` if your API makes them idempotent | `POST`, `PATCH`, unless the API supports an idempotency key | | Status | `0` (network or timeout), `502`, `503`, `504` | `400`, `401`, `403`, `404`, `409`, `422` — they will fail the same way | | Error type | `HttpErrorResponse` | Your own errors thrown by mapping or validation code | 401 is left to the auth interceptor, which refreshes the token rather than retrying blindly. ## The retry interceptor RxJS 7's `retry` operator takes a configuration object: **`count`** is the maximum number of resubscriptions, and **`delay`** may be a function `(error, retryCount) => ObservableInput`. Returning `timer(ms)` waits and retries; returning `throwError(() => error)` stops and propagates the error. That turns the policy into one function: ```ts retry({ count: 2, delay: (error, attempt) => isTransient(error) ? timer(300 * 2 ** (attempt - 1)) : throwError(() => error), }) ``` The first retry gets `attempt` 1, so the waits are 300 ms, then 600 ms. Two things to know about retrying *inside* an interceptor: - `retry` resubscribes to the Observable returned by `next(req)`. That repeats the network call and any subscription-time work downstream, but code that downstream interceptors ran when `next()` was first called, such as cloning the request with a header, is not re-run: each retry sends the same prepared request. - Each retry is a new network request. With the default `FetchBackend`, a request's `timeout` option applies to each attempt separately. ## The error interceptor Its job is to turn transport failures into something the app can act on, once, in one place: 1. `catchError` on `next(req)`. 2. Narrow with `error instanceof HttpErrorResponse`; let anything else pass unchanged. 3. Report: a user-visible notice for 5xx and status 0, a monitoring event with method, URL and status (never the token or the body of sensitive calls). 4. Optionally **map** to an app-level error class so services and components do not depend on `HttpErrorResponse` details. 5. **Rethrow** with `throwError(() => mappedOrOriginal)`. Rethrowing is the rule interviewers probe. Returning `EMPTY` or `of(null)` "handles" the error by making the caller's Observable complete quietly or emit a fake value: a component waiting on it with the `async` pipe or a signal shows nothing, and code after the call proceeds as if it succeeded. ## Per-request opt-outs Some calls expect failure — probing whether a resource exists, or a background refresh that should stay silent. Rather than hard-coding URLs, define `export const SILENT_ERRORS = new HttpContextToken<boolean>(() => false)` and let callers pass `context: new HttpContext().set(SILENT_ERRORS, true)`. The error interceptor still rethrows but skips the notice. ## Where retries belong Retrying in an interceptor applies one policy to the whole app, which suits transient infrastructure failures. Some calls need their own policy — a search box that should never retry because a newer query will follow, or a payment call that must not repeat. Those callers can opt out through an `HttpContextToken`, or the policy can live in the data service for that endpoint instead. Either way, the retry count multiplied by the number of clients is load on a server that is already failing, so keep counts small and delays growing. ## Mistakes to avoid - Retrying every failure, including 4xx responses and POSTs. - Retrying without a delay, which hammers a struggling server at the worst moment. - Reporting inside the retry loop, so recovered failures still produce toasts. - Swallowing errors with `EMPTY`. - Leaking tokens or personal data into error reports.
- What does the caller see if an Angular error interceptor returns EMPTY from catchError?The caller's Observable completes without emitting a value or an error. A `subscribe` error handler never runs, an `async` pipe keeps showing its initial `null`, and a signal created from the Observable keeps its initial value, so the UI looks stuck or empty while code that awaited success continues. Rethrow instead, optionally as a mapped app-level error.
- Why should a retry interceptor not retry a POST that failed with status 0?Status 0 means the app did not receive a response, not that the server did nothing. The POST may have reached the server and created the order before the connection dropped, so retrying could create a duplicate. Retry non-idempotent requests only when the API supports an idempotency key that lets the server recognise the repeat.
saying these in an interview costs you the question
- Every failed request should be retried a few times, whatever its method.
- Retrying a 404 or 400 can succeed if you wait long enough.
- Returning EMPTY from catchError is a clean way to handle HTTP errors globally.
- The error interceptor should be listed after the retry interceptor to catch errors earlier.
- Retrying immediately without delay is the safest way to recover from a 503.