Ch. 4 · Angular

Angular HTTP Error Handling: catchError and Safe Retries

Handle Angular HttpClient failures without stopping a search stream. Learn where catchError belongs, when retries are safe and how to test recovery.

~4 min readintermediate

An Angular HTTP failure is more than a place to add console.error. The UI needs to stop showing progress, preserve useful input and provide a recovery path. In an RxJS workflow, the location of error handling also determines whether later user actions still work.

A particularly useful interview scenario is a search box that stops working after its first failed request. The request is nested inside a stream of search terms. Catching an error at the wrong boundary can replace the whole search stream rather than only the failed request.

Keep errors inside the request boundary

This service fragment assumes HttpClient is already provided and that /api/search returns an array of items. Its TypeScript generic describes the expected data; it does not validate the server’s JSON at runtime.

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import {
  catchError, debounceTime, distinctUntilChanged,
  map, startWith, switchMap
} from 'rxjs/operators';

type Item = { id: string; title: string };
type SearchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; items: Item[] }
  | { status: 'error'; message: string };

@Injectable({ providedIn: 'root' })
export class SearchService {
  private readonly http = inject(HttpClient);

  search(terms: Observable<string>): Observable<SearchState> {
    return terms.pipe(
      map(term => term.trim()),
      debounceTime(250),
      distinctUntilChanged(),
      switchMap(term => {
        if (!term) return of<SearchState>({ status: 'idle' });
        return this.http.get<Item[]>('/api/search', {
          params: { q: term }
        }).pipe(
          map((items): SearchState => ({ status: 'success', items })),
          startWith<SearchState>({ status: 'loading' }),
          catchError(() => of<SearchState>({
            status: 'error',
            message: 'Search failed. Change the query to try again.'
          }))
        );
      })
    );
  }
}
TypeScript

The inner catchError replaces one failed request with an error state. It does not replace the outer stream of future terms. A new term can still start another request.

By contrast, putting catchError(() => of(fallback)) after the entire switchMap can recover the outer error with one fallback emission and then complete that replacement. That may be appropriate for a one-shot operation, but it is usually wrong for a search box expected to keep listening.

Understand what cancellation means

switchMap unsubscribes from the previous inner Observable when a newer term reaches it. With HttpClient, unsubscription can abort an in-progress request. The displayed state in this example follows the debounced query, so the previous results may remain briefly while the user is still typing; use a separate stale indicator if the product needs that distinction.

Cancellation does not reverse a server mutation. Do not use switching as proof that an obsolete payment, order or save could not have happened. For writes, clarify whether work should be queued, ignored while busy or checked against an expected record version.

Decide whether a retry is safe

A retry is another subscription and can send another request. Before adding it, ask:

  • Is the failure plausibly temporary, such as a transient connection problem?
  • Is repeating the operation safe, or does the server support an idempotency key?
  • Is there a bounded attempt count and total time budget?
  • Should a server-provided retry delay be respected?

Retrying an invalid form response will not repair the input. Retrying an unauthorized request without a coordinated authentication strategy can create a loop. Retrying a mutation after a timeout may duplicate its effect if the server already committed it.

Use bounded delay and jitter where appropriate instead of immediately multiplying load during an outage. Keep the user informed and make a manual retry preserve their draft. A centralized interceptor can implement shared mechanics, but operation-specific policy must remain explicit.

Avoid one boolean for overlapping work

A global loading = true before each request and loading = false in every finalize callback is easy to get wrong. One request can finish while another is active. A canceled old request can also clear a new request’s indicator.

The example emits request-scoped states inside the switched inner stream. For a genuinely global progress bar, maintain a count with cleanup on completion, error and unsubscription, and exclude background requests that should not block the screen.

Test recovery deliberately

Test a successful query, then a failed query, then another successful query. The final result confirms that the trigger stream remains usable. Test empty results separately from failure. Complete two controlled requests out of order and verify that obsolete work cannot replace the active query.

For retry policy, assert which failures retry, how many requests occur and what happens after the limit. A test that merely checks a success response does not exercise the behavior most likely to break under pressure.

Interview answer

“I catch recoverable errors at the request boundary so future search triggers remain alive. I represent loading, success and failure explicitly. I choose cancellation and retry policy based on the operation, because unsubscribing cannot undo a server write. I test failure followed by recovery, not just the happy path.”

Continue with RxJS flattening operators, Angular HTTP scenarios and Angular’s HTTP documentation.

More in Angular

esc