HTTP Error Handling
Network requests fail for all sorts of reasons: the server is down, the user is offline, a token expired, or the backend returned a validation error. A robust Angular app never lets those failures crash silently or surface a cryptic stack trace to the user. The HttpClient reports every failure as an HttpErrorResponse, and RxJS operators like catchError and retry let you recover, transform, or re-throw those errors in a predictable way. This page shows how to distinguish error categories, retry transient failures, and provide sensible fallbacks.
Understanding HttpErrorResponse
When a request fails, HttpClient pushes an HttpErrorResponse down the error channel of the observable. This object distinguishes two fundamentally different failure modes through its error property:
- Client-side / network errors — the request never reached the server (DNS failure, offline, CORS, a thrown
TypeError). Hereerroris anErrorEvent(or a DOMProgressEvent) andstatusis0. - Server-side errors — the backend responded with a non-2xx status (
404,401,500, …). Hereerroris the parsed response body andstatusholds the HTTP code.
import { HttpErrorResponse } from '@angular/common/http';
function describeError(err: HttpErrorResponse): string {
if (err.status === 0) {
// Client-side or network error.
console.error('Network error:', err.error);
return 'Could not reach the server. Check your connection.';
}
// Server returned an unsuccessful response code.
console.error(`Backend returned ${err.status}`, err.error);
return `Request failed (${err.status}). Please try again.`;
}
Output:
Backend returned 404 {message: "User not found"}
Catching errors in a service
Keep error handling close to the request. A service method should return a clean, typed stream and translate low-level HttpErrorResponse objects into something the component can consume. Use catchError from RxJS and throwError to re-emit a user-friendly error.
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { catchError, Observable, throwError } from 'rxjs';
export interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private baseUrl = '/api/users';
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
catchError((err: HttpErrorResponse) => this.handleError(err)),
);
}
private handleError(err: HttpErrorResponse) {
const message =
err.status === 0
? 'Network error — please check your connection.'
: `Server error ${err.status}: ${err.error?.message ?? err.message}`;
return throwError(() => new Error(message));
}
}
Always return
throwError(() => new Error(...))(the factory form) rather than the value form. The factory defers creation until subscription, capturing an accurate stack trace.
Retrying transient failures
Many failures are temporary — a brief network blip or a rate-limited endpoint. The retry operator re-subscribes to the source observable, re-issuing the request. Used naively it hammers the server, so configure it with a delay and a cap, and only retry on errors worth retrying.
import { retry, timer } from 'rxjs';
getUserWithRetry(id: number): Observable<User> {
return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
retry({
count: 3,
delay: (error: HttpErrorResponse, retryCount) => {
// Don't retry client errors (4xx) — they won't succeed on retry.
if (error.status >= 400 && error.status < 500) {
throw error;
}
// Exponential backoff: 500ms, 1000ms, 2000ms.
return timer(2 ** (retryCount - 1) * 500);
},
}),
catchError((err: HttpErrorResponse) => this.handleError(err)),
);
}
The delay callback decides per-attempt whether to retry: throwing re-raises the error immediately, while returning a notifier observable (here a timer) schedules the next attempt after the delay elapses.
When to retry
| Status / condition | Retry? | Reason |
|---|---|---|
0 (network) | Yes | Often a transient connectivity blip |
408 Request Timeout | Yes | Server was slow; a retry may succeed |
429 Too Many Requests | Yes* | Respect Retry-After; back off before retrying |
500, 502, 503, 504 | Yes | Transient server/infra failures |
400, 401, 403, 404 | No | Deterministic — retrying won’t change the result |
Providing a fallback
Sometimes you would rather degrade gracefully than propagate an error. Returning a default value with of(...) inside catchError keeps the stream alive so the UI can render placeholder content.
import { of } from 'rxjs';
getUsersSafe(): Observable<User[]> {
return this.http.get<User[]>(this.baseUrl).pipe(
catchError((err: HttpErrorResponse) => {
console.error('Falling back to empty list', err);
return of([]); // Component still receives a valid array.
}),
);
}
Surfacing errors in a component
In a standalone component, subscribe and route the error into a signal so the template can react with the new control-flow syntax.
import { Component, inject, signal } from '@angular/core';
import { UserService, User } from './user.service';
@Component({
selector: 'app-user',
standalone: true,
template: `
@if (error()) {
<p class="error">{{ error() }}</p>
} @else if (user()) {
<h2>{{ user()!.name }}</h2>
} @else {
<p>Loading…</p>
}
`,
})
export class UserComponent {
private service = inject(UserService);
user = signal<User | null>(null);
error = signal<string | null>(null);
load(id: number) {
this.error.set(null);
this.service.getUser(id).subscribe({
next: (u) => this.user.set(u),
error: (e: Error) => this.error.set(e.message),
});
}
}
Centralize cross-cutting concerns — auth refresh on
401, global toasts, logging — in a functional interceptor instead of repeating logic in every service. See HTTP interceptors below.
Best practices
- Always inspect
status === 0to separate network failures from server responses, and craft distinct user messages for each. - Handle errors in the service layer so components receive clean, typed streams and simple
Errorobjects. - Use
retrywithcount, exponentialdelay, and a guard that skips non-retryable4xxerrors. - Honor
Retry-Afterheaders for429/503instead of guessing the backoff window. - Prefer
of(defaultValue)fallbacks when partial UI is better than a hard failure; re-throw when the caller genuinely needs to know. - Never swallow errors silently — log them (or report to an error-tracking service) even when you recover.
- Move global error policies (logging, auth refresh, toasts) into a functional interceptor to avoid duplication.