Angular HTTP Error Handling and Retry: Patterns for Resilient Apps [2026]
Network requests fail. The endpoint is down for ten seconds; the user lost WiFi; the server returned 500 because the database hiccupped. A polished app degrades gracefully instead of showing a generic "oops" toast. This lesson walks the layered approach: per-request error handling, global error normalization, retry-with-backoff strategies, and user-facing error UI patterns.
This is lesson 6.4 of the Angular Tutorial. You should have read lessons 6.1-6.3 on HttpClient, interceptors, and httpResource.
Three layers of error handling #
| Layer | Where it lives | What it does |
|---|---|---|
| Global | Interceptor + global ErrorHandler | Normalize error shapes, log, send to Sentry |
| Per-resource | httpResource.error() signal in components |
Show error UI per-feature |
| Per-call | catchError in RxJS pipe |
One-off transformations |
Good apps use all three. Global handles the cross-cutting concerns (logging, normalization), per-resource handles UI, per-call is the escape hatch for one-off cases.
Error shapes from HttpClient #
Failed requests reject with an HttpErrorResponse:
interface HttpErrorResponse {
status: number; // HTTP status (e.g. 404, 500)
statusText: string; // "Not Found", "Internal Server Error"
error: any; // server's response body (JSON-parsed if possible)
url: string | null; // the requested URL
ok: false;
}
Network failures (DNS, CORS, offline) emit with status: 0. The error field contains the error event.
Pattern 1: Global error normalization (interceptor) #
Lesson 6.2 introduced this; the realistic version:
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export class AppError extends Error {
constructor(
public status: number,
message: string,
public details?: any,
public correlationId?: string,
) {
super(message);
this.name = 'AppError';
}
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
const correlationId = req.headers.get('X-Trace-ID') ?? undefined;
let message: string;
switch (err.status) {
case 0: message = 'Network unavailable. Check your connection.'; break;
case 401: message = 'Your session has expired. Please sign in again.'; break;
case 403: message = 'You don\'t have permission to do that.'; break;
case 404: message = 'The requested resource was not found.'; break;
case 429: message = 'Too many requests. Try again in a moment.'; break;
case 503: message = 'The service is temporarily unavailable.'; break;
default:
message = err.error?.message ?? err.statusText ?? 'Something went wrong.';
}
return throwError(() => new AppError(err.status, message, err.error, correlationId));
}),
);
};
Now every component sees AppError with human-readable message and consistent status. Different servers' error shapes don't matter — they're normalized at the boundary.
Pattern 2: Global ErrorHandler for uncaught errors #
When an error escapes all your component-level handling, the framework's ErrorHandler catches it. Override it:
import { ErrorHandler, inject } from '@angular/core';
import { AppError } from './error-types';
import { ToastService } from './toast.service';
export class AppErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: unknown) {
if (error instanceof AppError) {
this.toast.show(error.message);
// Optional: send to Sentry / Datadog with correlationId
} else {
console.error('Unexpected error:', error);
this.toast.show('An unexpected error occurred.');
}
}
}
// app.config.ts
providers: [
{ provide: ErrorHandler, useClass: AppErrorHandler },
]
This catches anything that bubbles up: thrown exceptions in templates, unhandled Promise rejections from effect() callbacks, async errors not caught by httpResource.
Pattern 3: Per-resource error UI #
For errors that should show inline (not as a toast), read from the resource's error signal:
export class UsersListComponent {
users = httpResource<User[]>(() => ({ url: '/api/users' }));
}
@if (users.isLoading()) {
<p>Loading users...</p>
} @else if (users.error(); as err) {
<div class="error-banner">
<p>{{ (err as AppError).message }}</p>
<button (click)="users.reload()">Retry</button>
</div>
} @else {
<ul>@for (u of users.value() ?? []; track u.id) { <li>{{ u.name }}</li> }</ul>
}
The interceptor normalized the error to AppError, the component reads its message, and the user gets a retry button. No generic "Oops" — they know what failed.
Pattern 4: Retry with exponential backoff #
For transient errors (network blips, 5xx), retry automatically before showing an error:
import { HttpInterceptorFn } from '@angular/common/http';
import { retry, timer, throwError } from 'rxjs';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
// Only retry idempotent verbs
if (req.method !== 'GET' && req.method !== 'HEAD') return next(req);
return next(req).pipe(
retry({
count: 3,
delay: (error, retryCount) => {
// Don't retry 4xx (client errors don't fix themselves)
if (error.status >= 400 && error.status < 500) {
return throwError(() => error);
}
// Exponential backoff: 250ms, 500ms, 1000ms
const delay = Math.min(250 * 2 ** (retryCount - 1), 5000);
return timer(delay);
},
}),
);
};
The retry operator subscribes to the source again — for HttpClient, that means a fresh request. Wrap with tap to log retries:
retry({
count: 3,
delay: (err, n) => {
console.warn(`Retry ${n} after ${err.status}`);
return timer(250 * 2 ** (n - 1));
},
})
Pattern 5: Don't retry mutations #
POST/PUT/PATCH/DELETE are often non-idempotent. Retrying could duplicate the operation. The retryInterceptor above guards by checking req.method. For POSTs that ARE safe to retry (uploads, idempotent creates with client-generated IDs), let them through:
import { HttpContextToken } from '@angular/common/http';
export const SAFE_TO_RETRY = new HttpContextToken<boolean>(() => false);
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
const safe = req.context.get(SAFE_TO_RETRY) || ['GET', 'HEAD'].includes(req.method);
if (!safe) return next(req);
return next(req).pipe(retry({ count: 3, delay: /* ... */ }));
};
// At a safe call site
this.http.post('/api/uploads', form, {
context: new HttpContext().set(SAFE_TO_RETRY, true),
});
Pattern 6: User-friendly error messages #
The interceptor's switch-on-status is the foundation, but for richer messaging, parse the server's error body. Common shapes:
// 422 Validation Error — show field-specific errors
if (err.status === 422 && err.error?.errors) {
// err.error.errors = { email: 'Already taken' }
return throwError(() => new ValidationError(err.error.errors));
}
// 409 Conflict — usually "this item was modified by someone else"
if (err.status === 409) {
return throwError(() => new AppError(409, 'This was modified by someone else. Please refresh.'));
}
A dedicated ValidationError lets form components show errors next to the offending field.
Pattern 7: Offline detection #
navigator.onLine is a hint, not gospel. Combine with HttpClient retries:
import { fromEvent, merge } from 'rxjs';
export const offlineInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err) => {
if (err.status === 0 && !navigator.onLine) {
// Wait for network to come back
return fromEvent(window, 'online').pipe(
take(1),
switchMap(() => next(req)), // retry once back online
);
}
return throwError(() => err);
}),
);
};
When the user comes back online, requests that failed during offline replay automatically. Combine with a toast: "Reconnecting… your changes will save shortly."
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Errors not normalized | Interceptor isn't in withInterceptors([...]) |
Add it |
| Retry loops forever | No max count OR retry condition wrong | retry({ count: 3, delay: ... }) and ensure 4xx skips retry |
| Toast doesn't fire | ErrorHandler not registered | Add { provide: ErrorHandler, useClass: AppErrorHandler } |
| POST retried and duplicated | Retry policy retries all verbs | Guard with if (req.method !== 'GET') |
err.error is a string instead of JSON |
Server returned text/plain for the error | The error field is the raw body — JSON-parse manually |
| 401 doesn't redirect to login | 401 handler is too late in the chain | Use a dedicated auth-401 interceptor BEFORE the generic error interceptor |
What's next #
Lesson 6.5 covers caching strategies — using httpResource + a cache layer for stale-while-revalidate. Lesson 6.6 closes Module 6 with server-sent events for streaming HTTP.
Try it yourself #
A full error-handling stack — normalize, retry, surface to user:
import { Component, inject } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors, HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, retry, throwError, timer } from 'rxjs';
import { httpResource } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
class AppError extends Error {
constructor(public status: number, message: string) { super(message); }
}
const errorAndRetryInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
retry({
count: 2,
delay: (err: HttpErrorResponse, n) => err.status >= 500 ? timer(500 * n) : throwError(() => err),
}),
catchError((err: HttpErrorResponse) => {
const msg = err.status === 0 ? 'Network offline' : err.error?.message ?? err.statusText;
return throwError(() => new AppError(err.status, msg));
}),
);
};
@Component({
selector: 'app-demo',
template: `
@if (data.isLoading()) { <p>Loading...</p> }
@else if (data.error(); as err) { <p>❌ {{ (err as AppError).message }} <button (click)="data.reload()">Retry</button></p> }
@else { <pre>{{ data.value() | json }}</pre> }
`,
})
class DemoComponent {
data = httpResource<unknown>(() => ({ url: 'https://nonexistent-domain.invalid/api' }));
}
bootstrapApplication(DemoComponent, { providers: [provideHttpClient(withFetch(), withInterceptors([errorAndRetryInterceptor]))] });
The nonexistent domain triggers status 0 → the retry interceptor doesn't retry (only 5xx) → the error interceptor normalizes the message → the component shows "Network offline" with a retry button.
Lesson 6.5 picks up caching strategies with httpResource.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


