DEV Community

ABDELAAZIZ OUAKALA
ABDELAAZIZ OUAKALA

Posted on

☢️ Enterprise Error Monitoring in Angular: Global Error Handlers vs Feature-Level Resilience

Enterprise Error Monitoring in Angular: Global Error Handlers vs Feature-Level Resilience

"The best Angular applications don't prevent every error—they recover gracefully when errors inevitably happen."

In enterprise Angular applications, error handling is not a feature you bolt on at the end. It's an architectural concern that spans every layer of your application—from the component rendering a retry button to the monitoring dashboard tracking distributed traces across microservices.

This guide is for senior Angular developers, software architects, engineering managers, and enterprise teams who need production-realistic strategies for building resilient frontend systems. We won't teach you how to write try/catch. We'll teach you how to design systems that survive failures.


Table of Contents


The Problem: Why Your Global ErrorHandler Isn't Enough

If your only error strategy is Angular's global ErrorHandler, you're treating symptoms—not designing resilience.

In a typical enterprise Angular application, errors can originate from:

  • Component lifecycle hooks (unexpected state mutations)
  • Service methods (business logic failures, validation errors)
  • HTTP requests (network failures, 5xx server errors, timeouts)
  • Third-party libraries (unhandled exceptions from external code)
  • Browser APIs (storage quota exceeded, geolocation denied)
  • WebSocket connections (connection drops, message parsing failures)

A single global catch-all treats a network timeout the same way it treats a fatal runtime exception. It shows the same generic error message for a recoverable API failure and an unrecoverable application crash. It provides no context about which feature failed, what the user was doing, or how to recover.

In enterprise Angular applications, a single global catch-all is the equivalent of a fire alarm that only tells you the building is on fire. Not which floor. Not which room. Not how to evacuate safely.

The consequence? Users abandon your application. Support teams waste hours hunting for reproduction steps. Developers deploy fixes blind, without understanding the blast radius. And your monitoring dashboards show a flat red line that tells you nothing actionable.


The Common Mistake: One Handler to Rule Them All

The most common anti-pattern we see in production Angular codebases looks like this:

// ANTI-PATTERN: The "God Handler"
@Injectable()
export class GodErrorHandler implements ErrorHandler {
  handleError(error: any): void {
    console.error('Something went wrong:', error);
    // Maybe send to console, maybe show an alert
    alert('An error occurred. Please refresh the page.');
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern fails for several reasons:

  1. No error categorization — A 401 authentication error and a TypeError from a third-party library are handled identically
  2. No recovery path — The user gets a generic alert with no retry, no fallback, no context
  3. No observability — Errors are logged to the console where they disappear in production builds
  4. No correlation — There's no way to trace an error from the frontend through to backend logs
  5. No user context — You don't know which feature failed, what action the user was performing, or their session state

Reality? Not scalable. Not resilient. Not enterprise-grade.


Enterprise Architecture: Layered Responsibility

Enterprise Angular applications don't rely on one global error handler. They build multiple layers of resilience. Each layer has a distinct responsibility and a distinct recovery contract.

+-------------------------------------------------------------+
|  USER INTERFACE LAYER                                       |
|  +-------------+  +-------------+  +---------------------+  |
|  | Component   |  | Fallback UI |  | Skeleton / Empty    |  |
|  | try/catch   |  | (retry btn) |  | State               |  |
|  +------+------+  +-------------+  +---------------------+  |
|         |                                                    |
|         | ErrorBoundary (conceptual)                         |
|         | catches child component errors                     |
+---------+---------------------------------------------------+
|  SERVICE LAYER                                             |
|  +-------------+  +-------------+  +---------------------+  |
|  | Business    |  | Retry       |  | Error Categorization|  |
|  | Logic       |  | Strategy    |  | (recoverable/fatal) |  |
|  +------+------+  +-------------+  +---------------------+  |
|         |                                                    |
|         | Domain errors with context                         |
+---------+---------------------------------------------------+
|  HTTP TRANSPORT LAYER                                      |
|  +-------------+  +-------------+  +---------------------+  |
|  | HTTP        |  | Auth        |  | Correlation ID      |  |
|  | Interceptor |  | Handling    |  | Injection           |  |
|  | (v20+ Fn)   |  | (401/403)   |  | (trace headers)     |  |
|  +------+------+  +-------------+  +---------------------+  |
|         |                                                    |
|         | Standardized error format                          |
+---------+---------------------------------------------------+
|  GLOBAL SAFETY NET                                         |
|  +---------------------------------------------------------+|
|  | Custom ErrorHandler                                      ||
|  | - Unexpected crashes                                     ||
|  | - Unhandled exceptions                                   ||
|  | - Crash reporting (Sentry)                               ||
|  | - Fatal error overlay                                    ||
|  +---------------------------------------------------------+|
+---------+---------------------------------------------------+
|  OBSERVABILITY LAYER                                       |
|  +-------------+  +-------------+  +---------------------+  |
|  | Structured  |  | Metrics &   |  | Distributed         |  |
|  | Logging     |  | Traces      |  | Tracing             |  |
|  | (JSON)      |  | (dashboards)|  | (OpenTelemetry)     |  |
|  +-------------+  +-------------+  +---------------------+  |
+-------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

The Golden Rule: Handle errors as close to their source as possible. Escalate only when recovery is impossible.


Global ErrorHandler: The Last Safety Net

Angular's ErrorHandler is the last line of defense—not the first. Use it for what it's designed for: unexpected failures you couldn't predict.

What Belongs in Global ErrorHandler

Scenario Action
Unexpected runtime crashes Capture, log, report to monitoring
Unhandled exceptions from third-party libraries Capture, log, prevent app crash
Fatal errors requiring application restart Show fatal error overlay, provide error ID
Errors escaping all other layers Structured logging with full context
Crash reporting to Sentry / Azure App Insights Send with release version, user context

What Does NOT Belong in Global ErrorHandler

Scenario Where It Belongs
API failures (4xx, 5xx) HTTP Interceptor + Service layer
Network timeouts HTTP Interceptor with retry logic
Validation errors Component or Service layer
Business rule violations Service layer with domain errors
Empty data states Component with fallback UI

Modern Angular v20+ Configuration

Angular v20 (released May 28, 2025) continues the standalone-first approach introduced in v19. Components, directives, services, and pipes are standalone by default, and the CLI no longer generates suffixes in filenames. The ErrorHandler and HttpInterceptorFn APIs remain the recommended patterns for enterprise error handling.

// app.config.ts
import { ApplicationConfig, ErrorHandler } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { GlobalErrorHandler } from './core/errors/global-error.handler';
import { errorInterceptor } from './core/interceptors/error.interceptor';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    // Global safety net
    { provide: ErrorHandler, useClass: GlobalErrorHandler },

    // Modern HttpClient with functional interceptors
    provideHttpClient(
      withInterceptors([errorInterceptor])
    ),

    // Router with error handling
    provideRouter(routes),

    // Other providers...
  ]
};
Enter fullscreen mode Exit fullscreen mode

Think of Global ErrorHandler as insurance, not a strategy.


Feature-Level Recovery: Where Resilience Lives

This is where the real work happens. Components and services should recover from predictable failures without crashing the entire application.

The Recovery Hierarchy

Error Occurs
    |
    +--> Can we retry? --> Yes --> Retry with backoff --> Success? --> Done
    |                       |                           +--> No --> Escalate
    |                       +--> No --> Next option
    |
    +--> Can we degrade? --> Yes --> Show partial data / cached data
    |                       +--> No --> Next option
    |
    +--> Can we fallback? --> Yes --> Show empty state / placeholder
    |                       +--> No --> Next option
    |
    +--> Escalate to global handler --> Fatal error overlay
Enter fullscreen mode Exit fullscreen mode

What Feature-Level Recovery Handles

Pattern Implementation
API failures Retry with exponential backoff, circuit breaker
Validation errors Inline field feedback, not toast notifications
Partial data Skeleton screens + render what you have
Empty states Meaningful fallback UI with guidance
Business rule violations Contextual messages with recovery actions
Offline mode Cached data, read-only state, sync queue

Signal-Based Error State (Angular v20+)

// Modern reactive error state with Signals
import { signal, computed } from '@angular/core';

interface ErrorState {
  hasError: boolean;
  message: string;
  isRecoverable: boolean;
  retryCount: number;
  lastAttempt: number | null;
}

class FeatureErrorState {
  private readonly _state = signal<ErrorState>({
    hasError: false,
    message: '',
    isRecoverable: true,
    retryCount: 0,
    lastAttempt: null
  });

  // Public readonly signals
  readonly state = this._state.asReadonly();
  readonly hasError = computed(() => this._state().hasError);
  readonly canRetry = computed(() => 
    this._state().isRecoverable && this._state().retryCount < 3
  );
  readonly shouldShowFallback = computed(() => 
    this._state().hasError && !this.canRetry()
  );

  setError(error: Error, isRecoverable: boolean = true): void {
    this._state.update(current => ({
      ...current,
      hasError: true,
      message: this.getUserMessage(error),
      isRecoverable,
      lastAttempt: Date.now()
    }));
  }

  recordRetry(): void {
    this._state.update(current => ({
      ...current,
      retryCount: current.retryCount + 1
    }));
  }

  clearError(): void {
    this._state.set({
      hasError: false,
      message: '',
      isRecoverable: true,
      retryCount: 0,
      lastAttempt: null
    });
  }

  private getUserMessage(error: Error): string {
    // Map technical errors to user-friendly messages
    const messageMap: Record<string, string> = {
      'TimeoutError': 'The request took too long. Please check your connection and try again.',
      'NetworkError': 'Unable to connect to the server. You may be offline.',
      'NotFoundError': 'The requested resource was not found.',
      'default': 'Something went wrong. Please try again.'
    };
    return messageMap[error.name] || messageMap['default'];
  }
}
Enter fullscreen mode Exit fullscreen mode

HTTP Interceptors: Transport Standardization

Transport-level concerns belong in interceptors. Don't pollute your services with auth retries and correlation ID logic.

What Interceptors Handle

Concern Implementation
Authentication (401/403) Token refresh, redirect to login
Server errors (500+) Retry with circuit breaker pattern
Network failures Queue requests, retry strategy
Correlation IDs Inject trace headers on every request
Structured logging Standardize error format across all HTTP calls
Request/Response timing Performance metrics collection

Functional Interceptor (Angular v20+)

// core/interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, retry, throwError, timer } from 'rxjs';
import { LoggingService } from '../errors/logging.service';
import { AuthService } from '../auth/auth.service';

export const errorInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
  const logger = inject(LoggingService);
  const authService = inject(AuthService);
  const router = inject(Router);

  // Generate correlation ID for distributed tracing
  const correlationId = req.headers.get('X-Correlation-Id') || crypto.randomUUID();

  const clonedReq = req.clone({
    setHeaders: { 
      'X-Correlation-Id': correlationId,
      'X-Request-Time': Date.now().toString()
    }
  });

  return next(clonedReq).pipe(
    // Retry transient errors with exponential backoff
    retry({
      count: 3,
      delay: (error: HttpErrorResponse, retryCount: number) => {
        // Only retry on transient errors
        if (shouldRetry(error)) {
          const backoffMs = Math.min(
            Math.pow(2, retryCount) * 1000 + Math.random() * 1000,
            10000 // Max 10s backoff
          );
          logger.log('warn', `Retrying request (${retryCount}/3) for ${req.url} in ${backoffMs}ms`, {
            correlationId,
            retryCount,
            url: req.url,
            method: req.method
          });
          return timer(backoffMs);
        }
        return throwError(() => error);
      }
    }),
    catchError((error: HttpErrorResponse) => {
      const categorizedError = categorizeHttpError(error, correlationId, req);

      // Log transport error with full context
      logger.logTransportError(categorizedError);

      // Handle auth errors
      if (error.status === 401) {
        return handleUnauthorized(authService, router, req, next);
      }

      if (error.status === 403) {
        return handleForbidden(router, categorizedError);
      }

      return throwError(() => categorizedError);
    })
  );
};

function shouldRetry(error: HttpErrorResponse): boolean {
  // Retry on network errors (status 0) or server errors (5xx)
  return error.status === 0 || (error.status >= 500 && error.status < 600);
}

function categorizeHttpError(
  error: HttpErrorResponse, 
  correlationId: string,
  req: HttpRequest<unknown>
): TransportError {
  const status = error.status;

  if (status === 401) {
    return new TransportError('AUTH_REQUIRED', 'Authentication required', {
      status, correlationId, url: req.url, recoverable: false, type: 'AUTH'
    });
  }

  if (status === 403) {
    return new TransportError('FORBIDDEN', 'Access denied', {
      status, correlationId, url: req.url, recoverable: false, type: 'AUTH'
    });
  }

  if (status === 404) {
    return new TransportError('NOT_FOUND', 'Resource not found', {
      status, correlationId, url: req.url, recoverable: false, type: 'CLIENT'
    });
  }

  if (status >= 500) {
    return new TransportError('SERVER_ERROR', 'Server error occurred', {
      status, correlationId, url: req.url, recoverable: true, type: 'SERVER'
    });
  }

  if (status === 0) {
    return new TransportError('NETWORK_ERROR', 'Network connection failed', {
      status, correlationId, url: req.url, recoverable: true, type: 'NETWORK'
    });
  }

  return new TransportError('UNKNOWN', error.message, {
    status, correlationId, url: req.url, recoverable: false, type: 'UNKNOWN'
  });
}

function handleUnauthorized(
  authService: AuthService, 
  router: Router,
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
) {
  // Attempt token refresh
  return authService.refreshToken().pipe(
    switchMap(newToken => {
      const refreshedReq = req.clone({
        setHeaders: { Authorization: `Bearer ${newToken}` }
      });
      return next(refreshedReq);
    }),
    catchError(() => {
      // Refresh failed, redirect to login
      authService.logout();
      router.navigate(['/login'], { 
        queryParams: { returnUrl: router.url }
      });
      return throwError(() => new TransportError('SESSION_EXPIRED', 'Session expired'));
    })
  );
}

function handleForbidden(router: Router, error: TransportError) {
  // Redirect to access denied page
  router.navigate(['/access-denied'], { 
    state: { error: error.toJSON() }
  });
  return throwError(() => error);
}

// Transport Error Class
export class TransportError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly context: Record<string, unknown> = {}
  ) {
    super(message);
    this.name = 'TransportError';
  }

  toJSON() {
    return {
      code: this.code,
      message: this.message,
      ...this.context
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Interceptors standardize. Services specialize.


Observability: You Can't Fix What You Can't See

Enterprise Angular teams instrument every layer with structured telemetry. You need three pillars: logs, metrics, and traces.

The Three Pillars of Observability

Pillar What to Collect Why It Matters
Logs Structured JSON with context Debug specific incidents with full request history
Metrics Error rate, latency, throughput Detect trends and anomalies over time
Traces Distributed request flow Follow a single user action across frontend and backend

What to Include in Every Error Report

interface ErrorReport {
  // Error identification
  errorId: string;           // Unique error instance ID
  correlationId: string;       // Links frontend -> backend -> logs

  // Error classification
  type: 'recoverable' | 'fatal' | 'transient';
  severity: 'low' | 'medium' | 'high' | 'critical';
  code: string;              // Domain-specific error code

  // Context
  message: string;            // User-friendly message
  technicalMessage: string;   // Developer-facing details
  stack?: string;             // Source-mapped stack trace

  // User context
  userId?: string;
  sessionId: string;
  userAction: string;         // What was the user doing?
  feature: string;            // Which feature/module?

  // Environment
  releaseVersion: string;      // Git SHA + build number
  environment: 'development' | 'staging' | 'production';
  browser: string;
  os: string;
  screenResolution: string;

  // Request context
  url: string;
  method?: string;
  timestamp: string;          // ISO 8601

  // Performance
  timeToError: number;        // ms from page load to error
  memoryUsage?: number;       // JS heap size at error time
}
Enter fullscreen mode Exit fullscreen mode

Monitoring Stack Recommendations

Tool Purpose Integration
Sentry Error tracking, source maps, release health @sentry/angular
Azure Application Insights Azure-native monitoring, custom events @microsoft/applicationinsights-web
OpenTelemetry Vendor-neutral distributed tracing @opentelemetry/web
Datadog RUM Real user monitoring, session replay Browser SDK
LogRocket Session replay with error context Browser SDK

Modern UX for Failure: Graceful Degradation

Users don't need to know your backend is down. They need to keep working—or understand why they can't, with dignity.

The UX Recovery Ladder

1. PREVENT (best case)
   +--> Optimistic UI, request deduplication, caching

2. RETRY (user-initiated)
   +--> Retry button with countdown, auto-retry in background

3. DEGRADE (partial functionality)
   +--> Show cached data, disable write operations, read-only mode

4. FALLBACK (structured empty state)
   +--> Meaningful empty state, guidance, alternative actions

5. ESCALATE (last resort)
   +--> Fatal error overlay with error ID, support contact
Enter fullscreen mode Exit fullscreen mode

UX Patterns by Error Type

Error Type UX Pattern Example
Transient API failure Inline retry button with spinner "Unable to load comments. [Retry]"
Network offline Offline mode banner + cached data "You're offline. Showing cached data."
Partial data Render what works, skeleton for rest Dashboard with 3/5 widgets loaded
Empty result Empty state illustration + CTA "No orders yet. [Create your first order]"
Validation error Inline field error, no toast Red border + message below field
Fatal crash Full-screen error overlay "Something went wrong. Error ID: ABC-123"

The Senior Engineering Mindset

"Applications don't become reliable because they throw fewer exceptions. They become reliable because they recover predictably."

The shift from "fixing bugs" to "designing for failure" separates senior engineers from the rest.

The 5 R's of Error Resilience

  1. Recognize — Detect errors early, before they cascade
  2. Recover — Retry, fallback, degrade—never crash silently
  3. Report — Structured, contextual, actionable logs
  4. Review — Post-incident analysis, not blame
  5. Refine — Update patterns, improve monitoring, reduce recurrence

Errors Are Expected System States

In distributed systems, failures are not exceptional—they're inevitable. Network partitions happen. Services restart. Databases failover. The question isn't if your application will encounter an error. The question is how gracefully it responds.

Errors aren't bugs. They're expected system states. Design for recovery.


Production-Ready Code Examples

Custom Global ErrorHandler

// core/errors/global-error.handler.ts
import { ErrorHandler, Injectable, inject, NgZone } from '@angular/core';
import { LoggingService } from './logging.service';
import { CrashReporter } from './crash-reporter.service';
import { EnvironmentService } from '../environment/environment.service';

export enum ErrorSeverity {
  RECOVERABLE = 'recoverable',
  FATAL = 'fatal',
  WARNING = 'warning',
  INFO = 'info'
}

export interface StructuredError {
  type: ErrorSeverity;
  code: string;
  message: string;
  correlationId: string;
  feature: string;
  userAction: string;
  timestamp: number;
  stack?: string;
  releaseVersion: string;
  environment: string;
  userAgent: string;
  url: string;
}

@Injectable({ providedIn: 'root' })
export class GlobalErrorHandler implements ErrorHandler {
  private readonly logger = inject(LoggingService);
  private readonly crashReporter = inject(CrashReporter);
  private readonly env = inject(EnvironmentService);
  private readonly ngZone = inject(NgZone);

  // Prevent infinite error loops
  private handlingError = false;

  handleError(error: Error | any): void {
    // Prevent infinite loops from errors during error handling
    if (this.handlingError) {
      console.error('Recursive error in GlobalErrorHandler:', error);
      return;
    }

    this.handlingError = true;

    try {
      const structuredError = this.buildError(error);

      // Log locally first (never lose the error)
      this.logger.logFatal(structuredError);

      // Report to monitoring platform asynchronously
      // Use NgZone to ensure this runs outside Angular's change detection
      this.ngZone.runOutsideAngular(() => {
        this.crashReporter.capture(structuredError).catch((reportError: Error) => {
          console.error('Failed to report error:', reportError);
        });
      });

      // In production, show a graceful fatal error page
      if (this.env.isProduction()) {
        this.showFatalErrorOverlay(structuredError);
      } else {
        // In development, re-throw for debugging
        console.error('Development error:', structuredError);
      }
    } catch (handlerError) {
      // Last resort: console only
      console.error('ErrorHandler failed:', handlerError);
      console.error('Original error:', error);
    } finally {
      this.handlingError = false;
    }
  }

  private buildError(error: Error | any): StructuredError {
    const isErrorInstance = error instanceof Error;

    return {
      type: this.classifySeverity(error),
      code: this.extractErrorCode(error),
      message: isErrorInstance ? error.message : String(error),
      correlationId: this.generateCorrelationId(),
      feature: this.detectFeature(error),
      userAction: this.detectUserAction(),
      timestamp: Date.now(),
      stack: isErrorInstance ? this.cleanStackTrace(error.stack) : undefined,
      releaseVersion: this.env.getVersion(),
      environment: this.env.getEnvironment(),
      userAgent: navigator.userAgent,
      url: window.location.href
    };
  }

  private classifySeverity(error: any): ErrorSeverity {
    if (error?.name === 'FatalError' || error?.fatal === true) {
      return ErrorSeverity.FATAL;
    }
    if (error?.name === 'Warning' || error?.warning === true) {
      return ErrorSeverity.WARNING;
    }
    return ErrorSeverity.FATAL; // Default to fatal for unhandled errors
  }

  private extractErrorCode(error: any): string {
    if (error?.code) return error.code;
    if (error?.name) return `UNHANDLED_${error.name.toUpperCase()}`;
    return 'UNHANDLED_EXCEPTION';
  }

  private detectFeature(error: any): string {
    // Try to detect which feature/module caused the error
    // from stack trace or error metadata
    const stack = error?.stack || '';
    const featureMatch = stack.match(/features\/([a-zA-Z0-9_-]+)/);
    return featureMatch?.[1] || 'unknown';
  }

  private detectUserAction(): string {
    // Capture recent user interactions from a stored action log
    // This would be populated by a user action tracking service
    return (window as any).__lastUserAction || 'unknown';
  }

  private generateCorrelationId(): string {
    return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
  }

  private cleanStackTrace(stack?: string): string | undefined {
    if (!stack) return undefined;
    // Remove webpack internals and source map URLs for cleaner logs
    return stack
      .split('\n')
      .filter(line => !line.includes('webpack:'))
      .filter(line => !line.includes('zone.js'))
      .join('\n');
  }

  private showFatalErrorOverlay(error: StructuredError): void {
    // Create a user-friendly fatal error overlay
    // This prevents the white screen of death
    const overlay = document.createElement('div');
    overlay.id = 'fatal-error-overlay';
    overlay.innerHTML = `
      <div style="
        position: fixed;
        top: 0; left: 0; right: 0; bottom: 0;
        background: #1F2937;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 999999;
        font-family: system-ui, sans-serif;
      ">
        <div style="
          background: #FFFFFF;
          padding: 40px;
          border-radius: 16px;
          max-width: 480px;
          text-align: center;
          box-shadow: 0 25px 50px rgba(0,0,0,0.25);
        ">
          <div style="font-size: 48px; margin-bottom: 16px;">&#x1F6E1;</div>
          <h2 style="color: #1F2937; margin-bottom: 12px; font-size: 22px;">
            Something went wrong
          </h2>
          <p style="color: #4B5563; line-height: 1.6; margin-bottom: 24px;">
            We're sorry, but the application encountered an unexpected error. 
            Our team has been notified automatically.
          </p>
          <div style="
            background: #F5F5F5;
            padding: 12px 16px;
            border-radius: 8px;
            font-family: monospace;
            font-size: 13px;
            color: #6B7280;
            margin-bottom: 24px;
            word-break: break-all;
          ">
            Error ID: <strong style="color: #283A8F;">${error.correlationId}</strong>
          </div>
          <button onclick="window.location.reload()" style="
            background: #283A8F;
            color: #FFFFFF;
            border: none;
            padding: 12px 32px;
            border-radius: 8px;
            font-size: 15px;
            font-weight: 500;
            cursor: pointer;
            margin-right: 8px;
          ">
            Refresh Page
          </button>
          <button onclick="document.getElementById('fatal-error-overlay').remove()" style="
            background: transparent;
            color: #6B7280;
            border: 1px solid #E5E7EB;
            padding: 12px 24px;
            border-radius: 8px;
            font-size: 15px;
            cursor: pointer;
          ">
            Dismiss
          </button>
        </div>
      </div>
    `;
    document.body.appendChild(overlay);
  }
}
Enter fullscreen mode Exit fullscreen mode

HttpInterceptorFn with Retry, Circuit Breaker & Correlation IDs

// core/interceptors/error.interceptor.ts
import { 
  HttpInterceptorFn, 
  HttpErrorResponse, 
  HttpRequest, 
  HttpHandlerFn,
  HttpEvent
} from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { 
  catchError, 
  retry, 
  throwError, 
  timer, 
  Observable, 
  switchMap,
  tap
} from 'rxjs';
import { LoggingService } from '../errors/logging.service';
import { AuthService } from '../auth/auth.service';

// Circuit Breaker Service
@Injectable({ providedIn: 'root' })
export class CircuitBreakerService {
  private readonly states = new Map<string, CircuitState>();
  private readonly failureThreshold = 5;
  private readonly resetTimeoutMs = 30000;

  isOpen(host: string): boolean {
    const state = this.states.get(host);
    if (!state) return false;

    if (state.status === 'OPEN') {
      if (Date.now() - state.lastFailure > this.resetTimeoutMs) {
        state.status = 'HALF_OPEN';
        return false;
      }
      return true;
    }
    return false;
  }

  recordSuccess(host: string): void {
    const state = this.states.get(host);
    if (state) {
      state.failures = 0;
      state.status = 'CLOSED';
    }
  }

  recordFailure(host: string): void {
    let state = this.states.get(host);
    if (!state) {
      state = { failures: 0, status: 'CLOSED', lastFailure: 0 };
      this.states.set(host, state);
    }

    state.failures++;
    state.lastFailure = Date.now();

    if (state.failures >= this.failureThreshold) {
      state.status = 'OPEN';
    }
  }
}

interface CircuitState {
  failures: number;
  status: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
  lastFailure: number;
}

export const errorInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>, 
  next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
  const logger = inject(LoggingService);
  const authService = inject(AuthService);
  const router = inject(Router);
  const circuitBreaker = inject(CircuitBreakerService);

  // Generate or propagate correlation ID for distributed tracing
  const correlationId = req.headers.get('X-Correlation-Id') || generateCorrelationId();
  const requestStartTime = performance.now();

  const clonedReq = req.clone({
    setHeaders: { 
      'X-Correlation-Id': correlationId,
      'X-Client-Version': getAppVersion(),
      'X-Request-Time': new Date().toISOString()
    }
  });

  // Check circuit breaker before making request
  const host = new URL(req.url).host;
  if (circuitBreaker.isOpen(host)) {
    return throwError(() => new TransportError(
      'CIRCUIT_OPEN',
      `Service ${host} is temporarily unavailable due to repeated failures`,
      { correlationId, url: req.url, recoverable: true, type: 'CIRCUIT_BREAKER' }
    ));
  }

  return next(clonedReq).pipe(
    // Retry transient errors with exponential backoff and jitter
    retry({
      count: 3,
      delay: (error: HttpErrorResponse, retryCount: number) => {
        if (!shouldRetry(error)) {
          return throwError(() => error);
        }

        // Exponential backoff with full jitter
        const baseDelay = Math.pow(2, retryCount) * 1000;
        const jitter = Math.random() * baseDelay;
        const backoffMs = Math.min(baseDelay + jitter, 10000);

        logger.log('warn', `Retrying request (${retryCount}/3)`, {
          correlationId,
          retryCount,
          url: req.url,
          method: req.method,
          status: error.status,
          backoffMs
        });

        return timer(backoffMs);
      }
    }),
    // Record success for circuit breaker
    tap(() => {
      circuitBreaker.recordSuccess(host);

      // Log request timing
      const duration = performance.now() - requestStartTime;
      if (duration > 5000) {
        logger.log('warn', 'Slow request detected', {
          correlationId,
          url: req.url,
          duration: Math.round(duration),
          method: req.method
        });
      }
    }),
    catchError((error: HttpErrorResponse) => {
      const duration = performance.now() - requestStartTime;
      const categorizedError = categorizeHttpError(error, correlationId, req, duration);

      // Record failure for circuit breaker
      if (error.status >= 500 || error.status === 0) {
        circuitBreaker.recordFailure(host);
      }

      // Log transport error with full context
      logger.logTransportError(categorizedError);

      // Handle specific error types
      if (error.status === 401) {
        return handleUnauthorized(authService, router, req, next, correlationId);
      }

      if (error.status === 403) {
        return handleForbidden(router, categorizedError);
      }

      if (error.status === 429) {
        return handleRateLimit(error, categorizedError);
      }

      return throwError(() => categorizedError);
    })
  );
};

function generateCorrelationId(): string {
  return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}

function getAppVersion(): string {
  return (import.meta as any).env?.['NG_APP_VERSION'] || 'unknown';
}

function shouldRetry(error: HttpErrorResponse): boolean {
  // Retry on network errors (status 0) or server errors (5xx)
  // Don't retry client errors (4xx) except 408 (timeout) and 429 (rate limit)
  if (error.status === 408 || error.status === 429) return true;
  return error.status === 0 || (error.status >= 500 && error.status < 600);
}

function categorizeHttpError(
  error: HttpErrorResponse, 
  correlationId: string,
  req: HttpRequest<unknown>,
  duration: number
): TransportError {
  const status = error.status;

  const baseContext = {
    status,
    correlationId,
    url: req.url,
    method: req.method,
    duration: Math.round(duration),
    headers: Object.fromEntries(req.headers.entries())
  };

  switch (status) {
    case 400:
      return new TransportError('BAD_REQUEST', 'Invalid request data', {
        ...baseContext, recoverable: false, type: 'CLIENT'
      });
    case 401:
      return new TransportError('AUTH_REQUIRED', 'Authentication required', {
        ...baseContext, recoverable: false, type: 'AUTH'
      });
    case 403:
      return new TransportError('FORBIDDEN', 'Access denied', {
        ...baseContext, recoverable: false, type: 'AUTH'
      });
    case 404:
      return new TransportError('NOT_FOUND', 'Resource not found', {
        ...baseContext, recoverable: false, type: 'CLIENT'
      });
    case 408:
      return new TransportError('TIMEOUT', 'Request timeout', {
        ...baseContext, recoverable: true, type: 'TRANSIENT'
      });
    case 409:
      return new TransportError('CONFLICT', 'Resource conflict', {
        ...baseContext, recoverable: false, type: 'CLIENT'
      });
    case 422:
      return new TransportError('VALIDATION', 'Validation failed', {
        ...baseContext, recoverable: false, type: 'CLIENT', 
        validationErrors: error.error?.errors
      });
    case 429:
      return new TransportError('RATE_LIMIT', 'Too many requests', {
        ...baseContext, recoverable: true, type: 'TRANSIENT',
        retryAfter: error.headers.get('Retry-After')
      });
    case 500:
      return new TransportError('SERVER_ERROR', 'Internal server error', {
        ...baseContext, recoverable: true, type: 'SERVER'
      });
    case 502:
      return new TransportError('BAD_GATEWAY', 'Bad gateway', {
        ...baseContext, recoverable: true, type: 'SERVER'
      });
    case 503:
      return new TransportError('UNAVAILABLE', 'Service unavailable', {
        ...baseContext, recoverable: true, type: 'SERVER'
      });
    case 504:
      return new TransportError('GATEWAY_TIMEOUT', 'Gateway timeout', {
        ...baseContext, recoverable: true, type: 'SERVER'
      });
    case 0:
      return new TransportError('NETWORK_ERROR', 'Network connection failed', {
        ...baseContext, recoverable: true, type: 'NETWORK'
      });
    default:
      return new TransportError('UNKNOWN', error.message || 'Unknown error', {
        ...baseContext, recoverable: false, type: 'UNKNOWN'
      });
  }
}

function handleUnauthorized(
  authService: AuthService, 
  router: Router,
  req: HttpRequest<unknown>,
  next: HttpHandlerFn,
  correlationId: string
): Observable<HttpEvent<unknown>> {
  // Attempt silent token refresh
  return authService.refreshToken().pipe(
    switchMap(newToken => {
      const refreshedReq = req.clone({
        setHeaders: { 
          Authorization: `Bearer ${newToken}`,
          'X-Correlation-Id': correlationId
        }
      });
      return next(refreshedReq);
    }),
    catchError((refreshError) => {
      // Refresh failed, redirect to login
      authService.logout();
      router.navigate(['/login'], { 
        queryParams: { returnUrl: router.url },
        state: { error: 'SESSION_EXPIRED' }
      });
      return throwError(() => new TransportError(
        'SESSION_EXPIRED', 
        'Your session has expired. Please log in again.',
        { correlationId, recoverable: false, type: 'AUTH' }
      ));
    })
  );
}

function handleForbidden(router: Router, error: TransportError): Observable<never> {
  router.navigate(['/access-denied'], { 
    state: { error: error.toJSON() }
  });
  return throwError(() => error);
}

function handleRateLimit(error: HttpErrorResponse, transportError: TransportError): Observable<never> {
  const retryAfter = error.headers.get('Retry-After');
  if (retryAfter) {
    const delayMs = parseInt(retryAfter, 10) * 1000;
    return timer(delayMs).pipe(
      switchMap(() => throwError(() => transportError))
    );
  }
  return throwError(() => transportError);
}

// Transport Error Class
export class TransportError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly context: Record<string, unknown> = {}
  ) {
    super(message);
    this.name = 'TransportError';

    // Maintain proper stack trace in V8
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, TransportError);
    }
  }

  toJSON(): Record<string, unknown> {
    return {
      name: this.name,
      code: this.code,
      message: this.message,
      stack: this.stack,
      ...this.context
    };
  }

  isRecoverable(): boolean {
    return this.context['recoverable'] === true;
  }

  isAuthError(): boolean {
    return this.context['type'] === 'AUTH';
  }
}
Enter fullscreen mode Exit fullscreen mode

Feature-Level Recovery with Signals & Resource API

Angular's Resource API (resource(), rxResource(), and httpResource()) provides built-in loading states, error handling, and request management. However, as of Angular v20, there are known issues with error state clearing during reload() and HttpErrorResponse wrapping in rxResource(). The httpResource() API (experimental since v19.2) handles HTTP errors more cleanly by passing them through unwrapped.

// features/users/user-list.component.ts
import { 
  Component, 
  inject, 
  resource, 
  signal, 
  computed,
  effect
} from '@angular/core';
import { UserService } from './user.service';
import { ToastService } from '../../shared/services/toast.service';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

interface ErrorState {
  hasError: boolean;
  message: string;
  isRecoverable: boolean;
  retryCount: number;
  lastAttempt: number | null;
  errorType: 'network' | 'server' | 'validation' | 'unknown';
}

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [SkeletonListComponent, ErrorFallbackComponent, UserTableComponent],
  template: `
    <div class="user-list-container">
      <!-- Header -->
      <header class="list-header">
        <h1>Users</h1>
        <div class="header-actions">
          @if (users.isLoading()) {
            <span class="loading-indicator">Loading...</span>
          }
          <button (click)="refreshUsers()" [disabled]="users.isLoading()">
            Refresh
          </button>
        </div>
      </header>

      <!-- Loading State -->
      @if (users.isLoading() && !users.value()) {
        <app-skeleton-list [count]="10" />
      }

      <!-- Error State -->
      @if (errorState().hasError) {
        <app-error-fallback 
          [error]="errorState()"
          [canRetry]="canRetry()"
          (retry)="handleRetry()"
          (dismiss)="clearError()" />
      }

      <!-- Success State -->
      @if (users.hasValue() && !errorState().hasError) {
        @if (users.value()!.length === 0) {
          <app-empty-state 
            title="No users found"
            message="Get started by inviting your first team member."
            actionLabel="Invite User"
            (action)="openInviteDialog()" />
        } @else {
          <app-user-table 
            [data]="users.value()!"
            (edit)="editUser($event)"
            (delete)="confirmDelete($event)" />

          <!-- Pagination -->
          <app-pagination 
            [currentPage]="currentPage()"
            [totalPages]="totalPages()"
            (pageChange)="goToPage($event)" />
        }
      }

      <!-- Partial Loading (refresh) -->
      @if (users.isLoading() && users.value()) {
        <div class="refresh-overlay">
          <span>Updating...</span>
        </div>
      }
    </div>
  `,
  styles: [`
    .user-list-container {
      padding: 24px;
      max-width: 1200px;
      margin: 0 auto;
    }
    .list-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 24px;
    }
    .header-actions {
      display: flex;
      align-items: center;
      gap: 12px;
    }
    .loading-indicator {
      color: #6B7280;
      font-size: 14px;
    }
    .refresh-overlay {
      position: fixed;
      top: 16px;
      right: 16px;
      background: #283A8F;
      color: white;
      padding: 8px 16px;
      border-radius: 8px;
      font-size: 13px;
      animation: fadeIn 0.3s ease;
    }
    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(-10px); }
      to { opacity: 1; transform: translateY(0); }
    }
  `]
})
export class UserListComponent {
  private readonly userService = inject(UserService);
  private readonly toastService = inject(ToastService);

  // Pagination state
  readonly currentPage = signal(1);
  readonly pageSize = signal(20);
  readonly totalItems = signal(0);
  readonly totalPages = computed(() => Math.ceil(this.totalItems() / this.pageSize()));

  // Error state with Signals
  readonly errorState = signal<ErrorState>({
    hasError: false,
    message: '',
    isRecoverable: true,
    retryCount: 0,
    lastAttempt: null,
    errorType: 'unknown'
  });

  readonly canRetry = computed(() => 
    this.errorState().isRecoverable && this.errorState().retryCount < 3
  );

  // Angular v20+ Resource API for declarative data fetching
  // NOTE: As of v20, resource() has known issues with error state clearing on reload()
  // and rxResource() wraps HttpErrorResponse. Consider using httpResource() for HTTP calls
  // or implementing a wrapper like rxResourceFixed for production use.
  readonly users = resource({
    request: () => ({ 
      page: this.currentPage(), 
      limit: this.pageSize() 
    }),
    loader: async ({ request }) => {
      // Clear previous errors on new request
      this.clearError();

      try {
        const response = await this.userService.getUsers(request.page, request.limit).toPromise();
        this.totalItems.set(response!.total);
        return response!.data;
      } catch (error) {
        this.handleError(error);
        throw error; // Re-throw so Resource API knows there was an error
      }
    },
    defaultValue: [] as User[]
  });

  // Effect to show toast on successful recovery
  constructor() {
    effect(() => {
      const value = this.users.value();
      const previousError = this.errorState();

      if (value && value.length > 0 && previousError.hasError) {
        this.toastService.success('Data recovered successfully');
        this.clearError();
      }
    });
  }

  refreshUsers(): void {
    this.users.reload();
  }

  handleRetry(): void {
    if (!this.canRetry()) return;

    this.errorState.update(state => ({
      ...state,
      retryCount: state.retryCount + 1,
      lastAttempt: Date.now()
    }));

    this.users.reload();
  }

  goToPage(page: number): void {
    if (page < 1 || page > this.totalPages()) return;
    this.currentPage.set(page);
  }

  private handleError(error: any): void {
    const isRecoverable = this.isRecoverableError(error);
    const errorType = this.classifyErrorType(error);
    const message = this.getUserMessage(error, errorType);

    this.errorState.set({
      hasError: true,
      message,
      isRecoverable,
      retryCount: this.errorState().retryCount,
      lastAttempt: Date.now(),
      errorType
    });

    // Show toast for non-blocking feedback
    if (!isRecoverable) {
      this.toastService.error(message);
    }
  }

  private isRecoverableError(error: any): boolean {
    // Network errors and 5xx are recoverable
    if (error.status === 0 || (error.status >= 500 && error.status < 600)) {
      return true;
    }
    // 408 (timeout) and 429 (rate limit) are recoverable
    if (error.status === 408 || error.status === 429) {
      return true;
    }
    return false;
  }

  private classifyErrorType(error: any): ErrorState['errorType'] {
    if (error.status === 0) return 'network';
    if (error.status >= 500) return 'server';
    if (error.status === 422) return 'validation';
    return 'unknown';
  }

  private getUserMessage(error: any, type: ErrorState['errorType']): string {
    const messages: Record<ErrorState['errorType'], string> = {
      network: 'Unable to connect. Please check your internet connection and try again.',
      server: 'Our servers are experiencing issues. We\'re working on it—please try again shortly.',
      validation: 'Some data could not be loaded. Please refresh to try again.',
      unknown: 'Something went wrong while loading users. Please try again.'
    };
    return messages[type];
  }

  clearError(): void {
    this.errorState.set({
      hasError: false,
      message: '',
      isRecoverable: true,
      retryCount: 0,
      lastAttempt: null,
      errorType: 'unknown'
    });
  }

  // Placeholder methods for completeness
  openInviteDialog(): void { /* ... */ }
  editUser(user: User): void { /* ... */ }
  confirmDelete(user: User): void { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Service with Retry Strategy

// features/users/user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, throwError, timer } from 'rxjs';
import { catchError, retry, timeout, map } from 'rxjs/operators';
import { LoggingService } from '../../core/errors/logging.service';

interface UserResponse {
  data: User[];
  total: number;
  page: number;
  limit: number;
}

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
  createdAt: string;
}

interface RetryConfig {
  count: number;
  delay: number;
  backoffMultiplier: number;
  maxDelay: number;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);
  private readonly logger = inject(LoggingService);
  private readonly baseUrl = '/api/v1/users';

  private readonly defaultRetryConfig: RetryConfig = {
    count: 3,
    delay: 1000,
    backoffMultiplier: 2,
    maxDelay: 10000
  };

  getUsers(page: number = 1, limit: number = 20): Observable<UserResponse> {
    const params = new HttpParams()
      .set('page', page.toString())
      .set('limit', limit.toString())
      .set('_timestamp', Date.now().toString()); // Cache busting

    return this.http.get<UserResponse>(this.baseUrl, { params }).pipe(
      // Timeout after 10 seconds
      timeout(10000),

      // Retry with configurable strategy
      this.withRetry(this.defaultRetryConfig),

      // Transform and validate response
      map(response => this.validateResponse(response)),

      // Catch and transform to domain errors
      catchError(error => {
        const domainError = this.mapToDomainError(error);
        this.logger.log('error', 'Failed to fetch users', {
          error: domainError,
          page,
          limit
        });
        return throwError(() => domainError);
      })
    );
  }

  getUserById(id: string): Observable<User> {
    return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
      timeout(5000),
      this.withRetry({ ...this.defaultRetryConfig, count: 2 }),
      catchError(error => throwError(() => this.mapToDomainError(error)))
    );
  }

  createUser(userData: CreateUserRequest): Observable<User> {
    // No retry for mutations—idempotency key required
    return this.http.post<User>(this.baseUrl, userData, {
      headers: { 'Idempotency-Key': crypto.randomUUID() }
    }).pipe(
      timeout(15000),
      catchError(error => throwError(() => this.mapToDomainError(error)))
    );
  }

  updateUser(id: string, updates: Partial<User>): Observable<User> {
    return this.http.patch<User>(`${this.baseUrl}/${id}`, updates, {
      headers: { 'Idempotency-Key': crypto.randomUUID() }
    }).pipe(
      timeout(10000),
      catchError(error => throwError(() => this.mapToDomainError(error)))
    );
  }

  deleteUser(id: string): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
      timeout(10000),
      catchError(error => throwError(() => this.mapToDomainError(error)))
    );
  }

  // Retry operator factory with exponential backoff
  private withRetry(config: RetryConfig) {
    return retry({
      count: config.count,
      delay: (error, retryCount) => {
        // Only retry transient errors
        if (!this.isRetryableError(error)) {
          return throwError(() => error);
        }

        const delayMs = Math.min(
          config.delay * Math.pow(config.backoffMultiplier, retryCount - 1),
          config.maxDelay
        );

        this.logger.log('warn', `Retrying API call (${retryCount}/${config.count})`, {
          delayMs,
          errorStatus: error.status,
          errorType: error.name
        });

        return timer(delayMs);
      }
    });
  }

  private isRetryableError(error: any): boolean {
    // Retry network errors and server errors
    if (error.status === 0) return true;
    if (error.status >= 500 && error.status < 600) return true;
    if (error.name === 'TimeoutError') return true;
    return false;
  }

  private validateResponse(response: any): UserResponse {
    // Runtime validation of API response shape
    if (!response || !Array.isArray(response.data)) {
      throw new DomainError('INVALID_RESPONSE', 'API returned unexpected data structure');
    }
    return response as UserResponse;
  }

  private mapToDomainError(error: any): DomainError {
    if (error instanceof DomainError) return error;

    if (error.name === 'TimeoutError') {
      return new DomainError(
        'REQUEST_TIMEOUT',
        'The request took too long. Please check your connection and try again.',
        { originalError: error }
      );
    }

    if (error.status === 0) {
      return new DomainError(
        'NETWORK_ERROR',
        'Unable to connect to the server. You may be offline.',
        { originalError: error }
      );
    }

    if (error.status === 404) {
      return new DomainError(
        'NOT_FOUND',
        'The requested resource was not found.',
        { originalError: error }
      );
    }

    if (error.status === 422) {
      return new DomainError(
        'VALIDATION_ERROR',
        'The provided data is invalid. Please check your input and try again.',
        { originalError: error, validationErrors: error.error?.errors }
      );
    }

    if (error.status >= 500) {
      return new DomainError(
        'SERVER_ERROR',
        'Our servers are experiencing issues. Please try again later.',
        { originalError: error, status: error.status }
      );
    }

    return new DomainError(
      'UNKNOWN_ERROR',
      'An unexpected error occurred. Please try again.',
      { originalError: error }
    );
  }
}

// Domain Error Class
export class DomainError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly context: Record<string, unknown> = {}
  ) {
    super(message);
    this.name = 'DomainError';

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DomainError);
    }
  }

  toJSON() {
    return {
      name: this.name,
      code: this.code,
      message: this.message,
      context: this.context
    };
  }
}

interface CreateUserRequest {
  name: string;
  email: string;
  role: string;
}
Enter fullscreen mode Exit fullscreen mode

Graceful Fallback UI Component

// shared/components/error-fallback/error-fallback.component.ts
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';

interface ErrorState {
  hasError: boolean;
  message: string;
  isRecoverable: boolean;
  retryCount: number;
  lastAttempt: number | null;
  errorType: 'network' | 'server' | 'validation' | 'unknown';
}

@Component({
  selector: 'app-error-fallback',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="error-fallback" [class.recoverable]="error().isRecoverable">
      <div class="error-visual">
        @switch (error().errorType) {
          @case ('network') {
            <div class="icon network-icon">&#x1F4E1;</div>
          }
          @case ('server') {
            <div class="icon server-icon">&#x1F527;</div>
          }
          @case ('validation') {
            <div class="icon validation-icon">&#x26A0;&#xFE0F;</div>
          }
          @default {
            <div class="icon default-icon">&#x1F6E1;</div>
          }
        }
      </div>

      <h3 class="error-title">
        @if (error().isRecoverable) {
          Something went wrong
        } @else {
          Service unavailable
        }
      </h3>

      <p class="error-message">{{ error().message }}</p>

      @if (error().retryCount > 0) {
        <p class="retry-info">
          Attempt {{ error().retryCount }} failed. 
          @if (canRetry()) {
            You can try again.
          }
        </p>
      }

      <div class="error-actions">
        @if (canRetry()) {
          <button 
            (click)="retry.emit()" 
            class="btn btn-primary"
            [disabled]="isRetrying()">
            @if (isRetrying()) {
              <span class="spinner"></span>
              Retrying...
            } @else {
              Try Again
            }
          </button>
        }

        <button 
          (click)="dismiss.emit()" 
          class="btn btn-secondary">
          Dismiss
        </button>
      </div>

      @if (!error().isRecoverable) {
        <div class="support-info">
          <p>Error ID: <code>{{ errorId }}</code></p>
          <p class="support-text">
            If this persists, contact support with the error ID above.
          </p>
        </div>
      }
    </div>
  `,
  styles: [`
    .error-fallback {
      padding: 32px;
      text-align: center;
      border-radius: 12px;
      background: #FEF2F2;
      border: 1px solid #FECACA;
      max-width: 480px;
      margin: 0 auto;
    }
    .error-fallback.recoverable {
      background: #F0FDFA;
      border-color: #99F6E4;
    }
    .error-visual {
      margin-bottom: 16px;
    }
    .icon {
      font-size: 48px;
      line-height: 1;
    }
    .network-icon { animation: pulse 2s infinite; }
    @keyframes pulse {
      0%, 100% { opacity: 1; }
      50% { opacity: 0.5; }
    }
    .error-title {
      font-size: 20px;
      font-weight: 600;
      color: #1F2937;
      margin-bottom: 8px;
    }
    .error-message {
      font-size: 15px;
      color: #4B5563;
      line-height: 1.6;
      margin-bottom: 20px;
    }
    .retry-info {
      font-size: 13px;
      color: #6B7280;
      margin-bottom: 16px;
    }
    .error-actions {
      display: flex;
      gap: 12px;
      justify-content: center;
      margin-bottom: 16px;
    }
    .btn {
      padding: 10px 24px;
      border-radius: 8px;
      font-size: 14px;
      font-weight: 500;
      cursor: pointer;
      border: none;
      transition: all 0.2s;
      display: inline-flex;
      align-items: center;
      gap: 8px;
    }
    .btn-primary {
      background: #283A8F;
      color: #FFFFFF;
    }
    .btn-primary:hover:not(:disabled) {
      background: #1E2A6B;
    }
    .btn-primary:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }
    .btn-secondary {
      background: transparent;
      color: #6B7280;
      border: 1px solid #E5E7EB;
    }
    .btn-secondary:hover {
      background: #F5F5F5;
    }
    .spinner {
      width: 16px;
      height: 16px;
      border: 2px solid #FFFFFF;
      border-top-color: transparent;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }
    @keyframes spin {
      to { transform: rotate(360deg); }
    }
    .support-info {
      padding-top: 16px;
      border-top: 1px solid #E5E7EB;
      font-size: 13px;
      color: #6B7280;
    }
    .support-info code {
      background: #F5F5F5;
      padding: 2px 8px;
      border-radius: 4px;
      font-family: monospace;
      font-size: 12px;
      color: #283A8F;
      font-weight: 600;
    }
    .support-text {
      margin-top: 4px;
    }
  `]
})
export class ErrorFallbackComponent {
  readonly error = input.required<ErrorState>();
  readonly canRetry = input<boolean>(true);
  readonly isRetrying = input<boolean>(false);

  readonly retry = output<void>();
  readonly dismiss = output<void>();

  readonly errorId = this.generateErrorId();

  private generateErrorId(): string {
    return `${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Structured Logging Service

// core/errors/logging.service.ts
import { Injectable, inject } from '@angular/core';
import { EnvironmentService } from '../environment/environment.service';

type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';

interface LogEntry {
  level: LogLevel;
  message: string;
  context: Record<string, unknown>;
  timestamp: string;
  correlationId: string;
  feature: string;
  userId?: string;
  sessionId: string;
  releaseVersion: string;
  environment: string;
  url: string;
  userAgent: string;
  memoryUsage?: number;
}

interface LogBatch {
  entries: LogEntry[];
  sent: boolean;
  retryCount: number;
}

@Injectable({ providedIn: 'root' })
export class LoggingService {
  private readonly env = inject(EnvironmentService);

  private readonly logs: LogEntry[] = [];
  private readonly maxBufferSize = 1000;
  private readonly flushIntervalMs = 30000; // 30 seconds
  private readonly batchSize = 50;
  private flushTimer: ReturnType<typeof setInterval> | null = null;

  // Batched logs waiting to be sent
  private pendingBatches: LogBatch[] = [];

  constructor() {
    // Start periodic flush
    this.startPeriodicFlush();

    // Flush on page unload
    if (typeof window !== 'undefined') {
      window.addEventListener('beforeunload', () => this.flushSync());
    }
  }

  log(
    level: LogLevel, 
    message: string, 
    context: Record<string, unknown> = {},
    options: { immediate?: boolean; correlationId?: string } = {}
  ): void {
    const entry: LogEntry = {
      level,
      message,
      context: this.sanitizeContext(context),
      timestamp: new Date().toISOString(),
      correlationId: options.correlationId || this.generateCorrelationId(),
      feature: (context['feature'] as string) || this.detectFeature(),
      userId: this.getCurrentUserId(),
      sessionId: this.getSessionId(),
      releaseVersion: this.env.getVersion(),
      environment: this.env.getEnvironment(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      memoryUsage: this.getMemoryUsage()
    };

    this.logs.push(entry);

    // Immediate flush for errors and fatals
    if (options.immediate || level === 'error' || level === 'fatal') {
      this.flushEntry(entry);
    }

    // Keep buffer bounded
    if (this.logs.length > this.maxBufferSize) {
      const overflow = this.logs.splice(0, this.logs.length - this.maxBufferSize);
      // Send overflow immediately to prevent data loss
      this.sendBatch(overflow);
    }
  }

  // Convenience methods
  debug(message: string, context?: Record<string, unknown>): void {
    this.log('debug', message, context);
  }

  info(message: string, context?: Record<string, unknown>): void {
    this.log('info', message, context);
  }

  warn(message: string, context?: Record<string, unknown>): void {
    this.log('warn', message, context);
  }

  error(message: string, context?: Record<string, unknown>): void {
    this.log('error', message, context, { immediate: true });
  }

  fatal(error: StructuredError): void {
    this.log('fatal', error.message, {
      ...error,
      feature: error.feature,
      correlationId: error.correlationId,
      stack: error.stack
    }, { immediate: true, correlationId: error.correlationId });
  }

  logTransportError(error: TransportError): void {
    this.log('error', `Transport error: ${error.code}`, {
      status: error.context['status'],
      url: error.context['url'],
      correlationId: error.context['correlationId'],
      recoverable: error.context['recoverable'],
      type: error.context['type'],
      duration: error.context['duration']
    }, { immediate: true, correlationId: error.context['correlationId'] as string });
  }

  getRecentLogs(count: number = 50): LogEntry[] {
    return this.logs.slice(-count);
  }

  getLogsByLevel(level: LogLevel): LogEntry[] {
    return this.logs.filter(log => log.level === level);
  }

  getLogsByFeature(feature: string): LogEntry[] {
    return this.logs.filter(log => log.feature === feature);
  }

  getLogsByCorrelationId(correlationId: string): LogEntry[] {
    return this.logs.filter(log => log.correlationId === correlationId);
  }

  // Flush all buffered logs
  flush(): Promise<void> {
    if (this.logs.length === 0) return Promise.resolve();

    const batch = this.logs.splice(0, this.batchSize);
    return this.sendBatch(batch);
  }

  // Synchronous flush for page unload
  private flushSync(): void {
    if (this.logs.length === 0) return;

    // Use sendBeacon for reliable delivery on page unload
    const payload = JSON.stringify({ logs: this.logs });
    navigator.sendBeacon?.('/api/logs', new Blob([payload], { type: 'application/json' }));
  }

  private flushEntry(entry: LogEntry): void {
    this.sendBatch([entry]).catch(err => {
      // If immediate flush fails, add to pending
      this.pendingBatches.push({
        entries: [entry],
        sent: false,
        retryCount: 0
      });
    });
  }

  private async sendBatch(entries: LogEntry[]): Promise<void> {
    if (entries.length === 0) return;

    const payload = {
      logs: entries,
      metadata: {
        batchSize: entries.length,
        sentAt: new Date().toISOString(),
        source: 'angular-client'
      }
    };

    try {
      // Send to monitoring endpoint
      const response = await fetch('/api/logs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
        keepalive: true
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      // Also send to Sentry if configured
      this.sendToSentry(entries);

    } catch (error) {
      // Queue for retry
      this.pendingBatches.push({
        entries,
        sent: false,
        retryCount: 0
      });
      throw error;
    }
  }

  private sendToSentry(entries: LogEntry[]): void {
    if (typeof window === 'undefined') return;

    const sentry = (window as any).Sentry;
    if (!sentry) return;

    entries.forEach(entry => {
      if (entry.level === 'fatal' || entry.level === 'error') {
        sentry.captureMessage(entry.message, {
          level: entry.level,
          extra: entry.context,
          tags: {
            feature: entry.feature,
            correlationId: entry.correlationId,
            release: entry.releaseVersion
          }
        });
      }
    });
  }

  private startPeriodicFlush(): void {
    this.flushTimer = setInterval(() => {
      this.flush().catch(() => {
        // Silently fail periodic flush—logs remain in buffer
      });

      // Retry failed batches
      this.retryFailedBatches();
    }, this.flushIntervalMs);
  }

  private retryFailedBatches(): void {
    const batchesToRetry = this.pendingBatches
      .filter(b => !b.sent && b.retryCount < 3)
      .slice(0, 5); // Max 5 batches per retry cycle

    batchesToRetry.forEach(batch => {
      batch.retryCount++;
      this.sendBatch(batch.entries)
        .then(() => { batch.sent = true; })
        .catch(() => { /* Will retry on next cycle */ });
    });

    // Clean up sent batches
    this.pendingBatches = this.pendingBatches.filter(b => !b.sent);
  }

  private sanitizeContext(context: Record<string, unknown>): Record<string, unknown> {
    // Remove sensitive data from logs
    const sanitized = { ...context };
    const sensitiveKeys = ['password', 'token', 'secret', 'apiKey', 'creditCard', 'ssn'];

    const sanitize = (obj: any): any => {
      if (typeof obj !== 'object' || obj === null) return obj;

      if (Array.isArray(obj)) {
        return obj.map(sanitize);
      }

      const result: any = {};
      for (const [key, value] of Object.entries(obj)) {
        if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
          result[key] = '[REDACTED]';
        } else if (typeof value === 'object' && value !== null) {
          result[key] = sanitize(value);
        } else {
          result[key] = value;
        }
      }
      return result;
    };

    return sanitize(sanitized);
  }

  private detectFeature(): string {
    // Detect current feature from URL or router state
    const path = window.location.pathname;
    const featureMatch = path.match(/^\/([^\/]+)/);
    return featureMatch?.[1] || 'unknown';
  }

  private getCurrentUserId(): string | undefined {
    // Get from auth service or session storage
    try {
      const user = JSON.parse(sessionStorage.getItem('currentUser') || '{}');
      return user.id;
    } catch {
      return undefined;
    }
  }

  private getSessionId(): string {
    let sessionId = sessionStorage.getItem('sessionId');
    if (!sessionId) {
      sessionId = crypto.randomUUID();
      sessionStorage.setItem('sessionId', sessionId);
    }
    return sessionId;
  }

  private generateCorrelationId(): string {
    return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
  }

  private getMemoryUsage(): number | undefined {
    if ('memory' in performance) {
      return (performance as any).memory?.usedJSHeapSize;
    }
    return undefined;
  }

  destroy(): void {
    if (this.flushTimer) {
      clearInterval(this.flushTimer);
    }
    this.flushSync();
  }
}
Enter fullscreen mode Exit fullscreen mode

Error Categorization & Severity Model

// core/errors/error-categorization.model.ts

/**
 * Error categorization for enterprise Angular applications.
 * 
 * Every error should be classified along three dimensions:
 * 1. Type: Where did it originate?
 * 2. Severity: How critical is it?
 * 3. Recoverability: Can the application continue?
 */

export enum ErrorType {
  // Client-side errors
  VALIDATION = 'validation',      // User input validation
  CLIENT_RUNTIME = 'client_runtime', // Component/service runtime
  RENDERING = 'rendering',         // Template/DOM errors

  // Transport errors
  NETWORK = 'network',             // Connection issues
  AUTH = 'auth',                   // Authentication/authorization
  SERVER = 'server',               // 5xx errors
  TIMEOUT = 'timeout',             // Request timeouts
  RATE_LIMIT = 'rate_limit',       // 429 errors

  // Business errors
  BUSINESS_RULE = 'business_rule',  // Domain logic violations
  NOT_FOUND = 'not_found',          // 404 - resource missing
  CONFLICT = 'conflict',            // 409 - state conflict

  // System errors
  THIRD_PARTY = 'third_party',      // External library failures
  BROWSER_API = 'browser_api',      // Storage, geolocation, etc.
  UNKNOWN = 'unknown'              // Unclassified
}

export enum ErrorSeverity {
  INFO = 'info',           // Log only, no user impact
  LOW = 'low',             // Minor degradation, no action needed
  MEDIUM = 'medium',       // Feature degraded, user can continue
  HIGH = 'high',           // Feature broken, workaround exists
  CRITICAL = 'critical'    // Application unusable, immediate attention
}

export enum Recoverability {
  AUTOMATIC = 'automatic',    // System can recover without user action
  USER_ACTION = 'user_action',  // User can retry or take alternative action
  MANUAL = 'manual',          // Requires developer/support intervention
  NONE = 'none'              // Unrecoverable, data may be lost
}

export interface CategorizedError {
  // Classification
  type: ErrorType;
  severity: ErrorSeverity;
  recoverability: Recoverability;

  // Identification
  code: string;              // Machine-readable error code
  message: string;           // User-friendly message
  technicalDetails?: string; // Developer-facing details

  // Context
  correlationId: string;
  feature: string;
  userAction: string;
  timestamp: number;

  // Recovery
  retryable: boolean;
  retryAfter?: number;      // Seconds until retry is safe
  fallbackAction?: string;   // What the user can do instead

  // Monitoring
  alert: boolean;           // Should this trigger an alert?
  alertChannel?: string;    // Which channel (pagerduty, slack, email)
}

export class ErrorClassifier {
  static classify(error: any): CategorizedError {
    if (error instanceof TransportError) {
      return this.classifyTransportError(error);
    }
    if (error instanceof DomainError) {
      return this.classifyDomainError(error);
    }
    return this.classifyUnknownError(error);
  }

  private static classifyTransportError(error: TransportError): CategorizedError {
    const type = error.context['type'] as string;

    const classificationMap: Record<string, Partial<CategorizedError>> = {
      'NETWORK': {
        type: ErrorType.NETWORK,
        severity: ErrorSeverity.MEDIUM,
        recoverability: Recoverability.USER_ACTION,
        retryable: true,
        message: 'Unable to connect. Please check your internet connection.'
      },
      'SERVER': {
        type: ErrorType.SERVER,
        severity: ErrorSeverity.HIGH,
        recoverability: Recoverability.AUTOMATIC,
        retryable: true,
        retryAfter: 5,
        message: 'Our servers are experiencing issues. Retrying automatically...',
        alert: true,
        alertChannel: 'slack'
      },
      'AUTH': {
        type: ErrorType.AUTH,
        severity: ErrorSeverity.HIGH,
        recoverability: Recoverability.USER_ACTION,
        retryable: false,
        message: 'Your session has expired. Please log in again.',
        alert: false
      },
      'CIRCUIT_BREAKER': {
        type: ErrorType.SERVER,
        severity: ErrorSeverity.CRITICAL,
        recoverability: Recoverability.AUTOMATIC,
        retryable: true,
        retryAfter: 30,
        message: 'Service temporarily unavailable. Please try again later.',
        alert: true,
        alertChannel: 'pagerduty'
      }
    };

    const classification = classificationMap[type] || {
      type: ErrorType.UNKNOWN,
      severity: ErrorSeverity.MEDIUM,
      recoverability: Recoverability.USER_ACTION,
      retryable: false,
      message: error.message
    };

    return {
      ...classification,
      code: error.code,
      message: classification.message || error.message,
      technicalDetails: error.stack,
      correlationId: error.context['correlationId'] as string || this.generateId(),
      feature: error.context['feature'] as string || 'unknown',
      userAction: 'http_request',
      timestamp: Date.now(),
      alert: classification.alert ?? (classification.severity === ErrorSeverity.CRITICAL)
    } as CategorizedError;
  }

  private static classifyDomainError(error: DomainError): CategorizedError {
    const code = error.code;

    const classificationMap: Record<string, Partial<CategorizedError>> = {
      'VALIDATION_ERROR': {
        type: ErrorType.VALIDATION,
        severity: ErrorSeverity.LOW,
        recoverability: Recoverability.USER_ACTION,
        retryable: false,
        alert: false
      },
      'NOT_FOUND': {
        type: ErrorType.NOT_FOUND,
        severity: ErrorSeverity.MEDIUM,
        recoverability: Recoverability.USER_ACTION,
        retryable: false,
        alert: false
      },
      'REQUEST_TIMEOUT': {
        type: ErrorType.TIMEOUT,
        severity: ErrorSeverity.MEDIUM,
        recoverability: Recoverability.USER_ACTION,
        retryable: true,
        alert: false
      },
      'SERVER_ERROR': {
        type: ErrorType.SERVER,
        severity: ErrorSeverity.HIGH,
        recoverability: Recoverability.AUTOMATIC,
        retryable: true,
        alert: true,
        alertChannel: 'slack'
      }
    };

    const classification = classificationMap[code] || {
      type: ErrorType.UNKNOWN,
      severity: ErrorSeverity.MEDIUM,
      recoverability: Recoverability.USER_ACTION,
      retryable: false,
      alert: false
    };

    return {
      ...classification,
      code,
      message: error.message,
      technicalDetails: JSON.stringify(error.context),
      correlationId: this.generateId(),
      feature: (error.context['feature'] as string) || 'unknown',
      userAction: 'business_operation',
      timestamp: Date.now(),
      alert: classification.alert ?? false
    } as CategorizedError;
  }

  private static classifyUnknownError(error: any): CategorizedError {
    return {
      type: ErrorType.UNKNOWN,
      severity: ErrorSeverity.HIGH,
      recoverability: Recoverability.MANUAL,
      code: 'UNKNOWN_ERROR',
      message: 'An unexpected error occurred. Our team has been notified.',
      technicalDetails: error?.stack || String(error),
      correlationId: this.generateId(),
      feature: 'unknown',
      userAction: 'unknown',
      timestamp: Date.now(),
      retryable: false,
      alert: true,
      alertChannel: 'slack'
    };
  }

  private static generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Toast Notification Service

// shared/services/toast.service.ts
import { Injectable, signal } from '@angular/core';

export interface Toast {
  id: string;
  message: string;
  type: 'success' | 'error' | 'warning' | 'info';
  duration: number;
  dismissible: boolean;
  action?: {
    label: string;
    handler: () => void;
  };
}

@Injectable({ providedIn: 'root' })
export class ToastService {
  private readonly _toasts = signal<Toast[]>([]);
  readonly toasts = this._toasts.asReadonly();

  show(
    message: string, 
    type: Toast['type'] = 'info', 
    options: Partial<Omit<Toast, 'id' | 'message' | 'type'>> = {}
  ): string {
    const id = crypto.randomUUID();
    const toast: Toast = {
      id,
      message,
      type,
      duration: options.duration ?? 5000,
      dismissible: options.dismissible ?? true,
      action: options.action
    };

    this._toasts.update(current => [...current, toast]);

    // Auto-dismiss after duration
    if (toast.duration > 0) {
      setTimeout(() => this.dismiss(id), toast.duration);
    }

    return id;
  }

  success(message: string, options?: Partial<Omit<Toast, 'id' | 'message' | 'type'>>): string {
    return this.show(message, 'success', options);
  }

  error(message: string, options?: Partial<Omit<Toast, 'id' | 'message' | 'type'>>): string {
    return this.show(message, 'error', { 
      duration: 8000, 
      dismissible: true,
      ...options 
    });
  }

  warning(message: string, options?: Partial<Omit<Toast, 'id' | 'message' | 'type'>>): string {
    return this.show(message, 'warning', { 
      duration: 6000, 
      ...options 
    });
  }

  info(message: string, options?: Partial<Omit<Toast, 'id' | 'message' | 'type'>>): string {
    return this.show(message, 'info', options);
  }

  dismiss(id: string): void {
    this._toasts.update(current => current.filter(t => t.id !== id));
  }

  dismissAll(): void {
    this._toasts.set([]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing Error Scenarios

// features/users/user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, DomainError } from './user.service';
import { LoggingService } from '../../core/errors/logging.service';

describe('UserService Error Handling', () => {
  let service: UserService;
  let httpMock: HttpTestingController;
  let loggerSpy: jasmine.SpyObj<LoggingService>;

  beforeEach(() => {
    loggerSpy = jasmine.createSpyObj('LoggingService', ['log', 'logTransportError']);

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        UserService,
        { provide: LoggingService, useValue: loggerSpy }
      ]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  describe('Network Errors', () => {
    it('should retry on network error and eventually fail with DomainError', (done) => {
      service.getUsers().subscribe({
        next: () => fail('Should have failed'),
        error: (error) => {
          expect(error).toBeInstanceOf(DomainError);
          expect(error.code).toBe('NETWORK_ERROR');
          expect(error.message).toContain('Unable to connect');
          done();
        }
      });

      // Simulate 3 failed requests (retry count)
      const req = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req.error(new ProgressEvent('Network error'));

      const req2 = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req2.error(new ProgressEvent('Network error'));

      const req3 = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req3.error(new ProgressEvent('Network error'));
    });

    it('should succeed on retry after initial failure', (done) => {
      const mockResponse = { data: [], total: 0, page: 1, limit: 20 };

      service.getUsers().subscribe({
        next: (response) => {
          expect(response.data).toEqual([]);
          done();
        },
        error: (err) => fail(`Should not fail: ${err}`)
      });

      // First request fails
      const req1 = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req1.error(new ProgressEvent('Network error'));

      // Second request succeeds
      const req2 = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req2.flush(mockResponse);
    });
  });

  describe('Server Errors (5xx)', () => {
    it('should classify 500 as recoverable server error', (done) => {
      service.getUsers().subscribe({
        error: (error) => {
          expect(error.code).toBe('SERVER_ERROR');
          expect(error.message).toContain('servers are experiencing issues');
          done();
        }
      });

      const req = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req.flush('Internal Server Error', { status: 500, statusText: 'Internal Server Error' });
    });

    it('should not retry on 501 (Not Implemented)', (done) => {
      // 501 is not in our retryable list
      service.getUsers().subscribe({
        error: (error) => {
          expect(error.code).toBe('SERVER_ERROR');
          done();
        }
      });

      const req = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req.flush('Not Implemented', { status: 501, statusText: 'Not Implemented' });

      // No additional requests should be made
      httpMock.verify();
    });
  });

  describe('Client Errors (4xx)', () => {
    it('should not retry on 404', (done) => {
      service.getUserById('nonexistent').subscribe({
        error: (error) => {
          expect(error.code).toBe('NOT_FOUND');
          done();
        }
      });

      const req = httpMock.expectOne('/api/v1/users/nonexistent');
      req.flush('Not Found', { status: 404, statusText: 'Not Found' });
    });

    it('should handle 422 validation errors with details', (done) => {
      const validationError = {
        errors: {
          email: ['Invalid email format'],
          name: ['Name is required']
        }
      };

      service.createUser({ name: '', email: 'invalid', role: 'user' }).subscribe({
        error: (error) => {
          expect(error.code).toBe('VALIDATION_ERROR');
          expect(error.context['validationErrors']).toEqual(validationError.errors);
          done();
        }
      });

      const req = httpMock.expectOne('/api/v1/users');
      req.flush(validationError, { status: 422, statusText: 'Unprocessable Entity' });
    });
  });

  describe('Timeout Handling', () => {
    it('should timeout after 10 seconds', (done) => {
      jasmine.clock().install();

      service.getUsers().subscribe({
        error: (error) => {
          expect(error.code).toBe('REQUEST_TIMEOUT');
          expect(error.name).toBe('TimeoutError');
          done();
        }
      });

      const req = httpMock.expectOne('/api/v1/users?page=1&limit=20');

      // Don't respond—let it timeout
      jasmine.clock().tick(10001);

      req.flush(null); // This will be ignored after timeout
      jasmine.clock().uninstall();
    });
  });

  describe('Error Logging', () => {
    it('should log errors with context', (done) => {
      service.getUsers().subscribe({
        error: () => {
          expect(loggerSpy.log).toHaveBeenCalledWith(
            'error',
            'Failed to fetch users',
            jasmine.objectContaining({
              page: 1,
              limit: 20
            })
          );
          done();
        }
      });

      const req = httpMock.expectOne('/api/v1/users?page=1&limit=20');
      req.flush('Error', { status: 500, statusText: 'Server Error' });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Architecture Diagrams

Layered Error Architecture

+-------------------------------------------------------------+
|                    USER INTERFACE LAYER                      |
|  +-------------+  +-------------+  +---------------------+  |
|  | Component   |  | Fallback UI |  | Skeleton / Empty    |  |
|  | try/catch   |  | (retry btn) |  | State               |  |
|  +------+------+  +-------------+  +---------------------+  |
|         |                                                    |
|         | ErrorBoundary (conceptual)                         |
|         | catches child component errors                     |
+---------+---------------------------------------------------+
|                    SERVICE LAYER                             |
|  +-------------+  +-------------+  +---------------------+  |
|  | Business    |  | Retry       |  | Error Categorization|  |
|  | Logic       |  | Strategy    |  | (recoverable/fatal) |  |
|  +------+------+  +-------------+  +---------------------+  |
|         |                                                    |
|         | Domain errors with context                         |
+---------+---------------------------------------------------+
|                    HTTP TRANSPORT LAYER                      |
|  +-------------+  +-------------+  +---------------------+  |
|  | HTTP        |  | Auth        |  | Correlation ID      |  |
|  | Interceptor |  | Handling    |  | Injection           |  |
|  | (v20+ Fn)   |  | (401/403)   |  | (trace headers)     |  |
|  +------+------+  +-------------+  +---------------------+  |
|         |                                                    |
|         | Standardized error format                          |
+---------+---------------------------------------------------+
|                    GLOBAL SAFETY NET                         |
|  +---------------------------------------------------------+|
|  | Custom ErrorHandler                                      ||
|  | - Unexpected crashes                                     ||
|  | - Unhandled exceptions                                   ||
|  | - Crash reporting (Sentry)                               ||
|  | - Fatal error overlay                                    ||
|  +---------------------------------------------------------+|
+---------+---------------------------------------------------+
|                    OBSERVABILITY LAYER                       |
|  +-------------+  +-------------+  +---------------------+  |
|  | Structured  |  | Metrics &   |  | Distributed         |  |
|  | Logging     |  | Traces      |  | Tracing             |  |
|  | (JSON)      |  | (dashboards)|  | (OpenTelemetry)     |  |
|  +-------------+  +-------------+  +---------------------+  |
+-------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Error Recovery Pipeline

Error Occurs
    |
    +--> [1] CATEGORIZE +------------------------------------------------+
    |     - Type: network / server / client / unknown                  |
    |     - Severity: info -> critical                                   |
    |     - Recoverability: automatic / user / manual                  |
    |                                                                    |
    +--> [2] RECOVER +---------------------------------------------------+
    |     - Retry? -> Exponential backoff                                |
    |     - Degrade? -> Partial data / cached                            |
    |     - Fallback? -> Empty state / placeholder                       |
    |     - Escalate? -> Global handler                                  |
    |                                                                    |
    +--> [3] FALLBACK UI +-----------------------------------------------+
    |     - Skeleton screens during retry                                |
    |     - Retry button with countdown                                  |
    |     - Offline mode banner                                          |
    |     - Fatal error overlay (last resort)                            |
    |                                                                    |
    +--> [4] LOG +-------------------------------------------------------+
    |     - Structured JSON with context                                 |
    |     - Correlation ID for tracing                                   |
    |     - User context and session                                     |
    |     - Performance metrics                                          |
    |                                                                    |
    +--> [5] ALERT +-----------------------------------------------------+
          - Critical -> PagerDuty / OpsGenie
          - High -> Slack / Teams
          - Medium -> Dashboard only
          - Low -> Log aggregation
Enter fullscreen mode Exit fullscreen mode

Monitoring Flow

+-----------------+     +-----------------+     +-----------------+
|   Angular App   |---->|  ErrorHandler   |---->| Logging Service |
|                 |     |                 |     |                 |
| - Components    |     | - Unexpected    |     | - Structured    |
| - Services      |     |   crashes       |     |   JSON logs     |
| - Interceptors  |     | - Fatal errors  |     | - Buffer mgmt   |
| - Global handler|     | - Crash reports |     | - Batch flush   |
+-----------------+     +-----------------+     +--------+--------+
                                                         |
                              +--------------------------+----------+
                              |                          |          |
                              v                          v          v
                    +-----------------+      +-----------------+  +-----------------+
                    |     Sentry      |      | Azure App       |  |  OpenTelemetry  |
                    |                 |      | Insights        |  |                 |
                    | - Error tracking|      | - Custom events |  | - Distributed   |
                    | - Source maps   |      | - Performance   |  |   traces        |
                    | - Release health|      | - Availability  |  | - Span context  |
                    +--------+--------+      +--------+--------+  +--------+--------+
                             |                      |                    |
                             +----------------------+--------------------+
                                                    |
                                                    v
                                          +-----------------+
                                          |   Dashboards    |
                                          |                 |
                                          | - Error rate    |
                                          | - Latency       |
                                          | - User impact   |
                                          | - Release corr. |
                                          +-----------------+
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Handle Errors Close to Their Source

The closer you handle an error to where it occurs, the more context you have for recovery. A component knows its UI state. A service knows its business rules. An interceptor knows its transport details.

// Good: Component handles its own state
@Component({...})
export class UserListComponent {
  readonly users = resource({
    loader: () => this.userService.getUsers(),
    defaultValue: []
  });

  // Component decides how to show loading/error/success
}

// Bad: Everything bubbles to global handler
// The user sees a generic error for a recoverable API failure
Enter fullscreen mode Exit fullscreen mode

2. Use Signals for Error State

Signals provide fine-grained reactivity without Zone.js overhead. They're perfect for error state that needs to trigger UI updates.

// Good: Signal-based error state
readonly errorState = signal<ErrorState>({...});
readonly canRetry = computed(() => ...);

// Bad: Imperative error handling with manual change detection
private errorSubject = new BehaviorSubject<ErrorState>(...);
Enter fullscreen mode Exit fullscreen mode

3. Implement Exponential Backoff for Retries

Linear retries can overwhelm a struggling server. Exponential backoff with jitter prevents the "thundering herd" problem.

// Good: Exponential backoff with jitter
const delayMs = Math.min(
  baseDelay * Math.pow(2, retryCount) + Math.random() * 1000,
  maxDelay
);

// Bad: Fixed interval retries
const delayMs = 1000; // Always waits 1 second
Enter fullscreen mode Exit fullscreen mode

4. Classify Error Severity

Not every error needs a PagerDuty alert. Classify errors by business impact to prevent alert fatigue.

Severity Response Time Channel Example
Critical < 5 min PagerDuty Payment processing down
High < 30 min Slack Core feature degraded
Medium < 4 hours Dashboard Non-critical API slow
Low Next sprint Log aggregation Minor UI glitch

5. Include Correlation IDs on Every Request

A correlation ID ties frontend errors to backend logs, making debugging distributed issues possible.

// Good: Every request gets a correlation ID
const correlationId = crypto.randomUUID();
req.clone({ setHeaders: { 'X-Correlation-Id': correlationId }});

// Bad: No tracing across services
// Frontend sees 500, backend sees nothing related
Enter fullscreen mode Exit fullscreen mode

6. Test Error Scenarios Explicitly

Error handling is code. Code needs tests. Explicitly test network failures, timeouts, 5xx errors, and validation failures.

// Good: Explicit error scenario tests
it('should show retry button on network error', () => {...});
it('should redirect to login on 401', () => {...});
it('should timeout after 10 seconds', () => {...});

// Bad: Only testing happy paths
Enter fullscreen mode Exit fullscreen mode

7. Use Source-Mapped Stack Traces in Production

Minified stack traces are useless. Configure your build pipeline to generate and upload source maps to your monitoring platform.

// angular.json
{
  "configurations": {
    "production": {
      "sourceMap": {
        "scripts": true,
        "styles": true,
        "hidden": true
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Sanitize Logs to Prevent Data Leaks

Never log passwords, tokens, or PII. Implement automatic redaction.

// Good: Automatic PII redaction
private sanitizeContext(context: Record<string, unknown>) {
  const sensitiveKeys = ['password', 'token', 'secret', 'apiKey'];
  // Recursively redact...
}

// Bad: Logging raw request objects
console.log('Request:', request); // May contain sensitive data
Enter fullscreen mode Exit fullscreen mode

9. Design Graceful Degradation Paths

Your application should work with reduced functionality when dependencies fail.

Feature Full Functionality Degraded Mode
User list Live data + search Cached data, no search
Dashboard All widgets Core widgets only
Comments Real-time Read-only cached
Notifications Push + in-app In-app only

10. Monitor Error Trends, Not Just Incidents

A single 500 error is an incident. A 10% increase in 500 errors over a week is a trend. Monitor both.


Common Mistakes to Avoid

Mistake Why It Fails The Fix
One global handler for everything No UX recovery, no context, no observability Layered strategy per concern
Swallowing errors silently Hidden bugs accumulate, no debugging trail Always log, then recover
Retrying all errors Wastes resources, amplifies failures Only retry transient errors (5xx, network, timeout)
Generic error messages Users can't act, support can't debug Contextual, actionable messages with error IDs
No correlation IDs Impossible to trace across services Inject on every HTTP request, propagate through logs
Missing fallback UI White screens, user abandonment Skeletons, empty states, retry buttons by default
Alerting on every error Alert fatigue, missed real issues Severity-based alerting with thresholds
No error testing Production surprises, regressions Explicit error scenario tests in CI
Logging raw objects Data leaks, log bloat Structured, sanitized, contextual logs
No circuit breaker Cascading failures, retry storms Circuit breaker for external dependencies
Ignoring memory leaks Progressive degradation Monitor heap usage, test long-running sessions
No offline support Complete failure on network loss Service Workers, cached data, sync queues

Monitoring Stack Recommendations

For Small to Medium Teams

Tool Purpose Cost
Sentry Error tracking, source maps, release health Free tier: 5k errors/mo
LogRocket Session replay with error context Free tier: 1k sessions/mo
Google Analytics 4 User behavior, error impact Free

For Enterprise Teams

Tool Purpose Integration
Sentry Enterprise Error tracking, performance, profiling @sentry/angular
Azure Application Insights Azure-native APM, custom events @microsoft/applicationinsights-web
Datadog RUM Real user monitoring, SLO tracking Browser SDK
OpenTelemetry Vendor-neutral distributed tracing @opentelemetry/web
PagerDuty / OpsGenie Incident management, on-call Webhook/API
Grafana Custom dashboards, log aggregation Loki/Prometheus

Self-Hosted Option

Tool Purpose Stack
Sentry On-Premise Full error tracking Docker/Kubernetes
Grafana + Loki + Tempo Logs, metrics, traces Open source
Prometheus + AlertManager Metrics, alerting Open source

Conclusion

Enterprise Angular applications don't become reliable because they throw fewer exceptions. They become reliable because they recover predictably.

The layered approach:

  1. Components recover UI state with Signals, Resource API, and fallback components
  2. Services define retry policies with exponential backoff and circuit breakers
  3. Interceptors standardize transport handling, auth, and correlation IDs
  4. Global ErrorHandler captures the unexpected and reports crashes
  5. Monitoring provides visibility through structured logs, metrics, and traces

The mindset shift:

Errors aren't bugs. They're expected system states. Design for recovery.

If your backend failed for five minutes, how gracefully would your Angular application respond? That's the real test of enterprise resilience.


I share day-to-day insight on web development, best practices, performance, and modern Angular architecture. Follow along for more — Ouakala Abdelaaziz, Full Stack Developer, Programming Mastery Academy.


I write about Angular architecture, enterprise UI patterns, and frontend best practices at Programming Mastery Academy — follow along for more breakdowns like this one.


📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.

🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:

🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.


Resources


What's the biggest improvement you've made to your application's error-handling architecture? Drop a comment below.

Top comments (0)