Angular HTTP Error Handling and Retry: Patterns for Resilient Apps [2026]

Link copied
Angular HTTP Error Handling and Retry: Patterns for Resilient Apps [2026]

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.

YouHow aggressively should I retry transient errors?
YouCap at 2-3 retries with exponential backoff (250ms, 500ms, 1000ms). Retry only on 5xx and network errors (status 0). NEVER retry 4xx (client errors don’t fix themselves) or non-idempotent mutations (POST/DELETE — they could duplicate). Above all, ensure the UI tells the user something is happening — silent retries that take 5 seconds feel like a broken app, even if the data eventually loads.

Lesson 6.5 picks up caching strategies with httpResource.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *