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
- The Common Mistake: One Handler to Rule Them All
- Enterprise Architecture: Layered Responsibility
- Global ErrorHandler: The Last Safety Net
- Feature-Level Recovery: Where Resilience Lives
- HTTP Interceptors: Transport Standardization
- Observability: You Can't Fix What You Can't See
- Modern UX for Failure: Graceful Degradation
- The Senior Engineering Mindset
- Production-Ready Code Examples
- Architecture Diagrams
- Best Practices
- Common Mistakes to Avoid
- Monitoring Stack Recommendations
- Conclusion
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.');
}
}
This pattern fails for several reasons:
-
No error categorization — A 401 authentication error and a
TypeErrorfrom a third-party library are handled identically - No recovery path — The user gets a generic alert with no retry, no fallback, no context
- No observability — Errors are logged to the console where they disappear in production builds
- No correlation — There's no way to trace an error from the frontend through to backend logs
- 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) | |
| +-------------+ +-------------+ +---------------------+ |
+-------------------------------------------------------------+
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...
]
};
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
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'];
}
}
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
};
}
}
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
}
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
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
- Recognize — Detect errors early, before they cascade
- Recover — Retry, fallback, degrade—never crash silently
- Report — Structured, contextual, actionable logs
- Review — Post-incident analysis, not blame
- 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;">🛡</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);
}
}
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';
}
}
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 { /* ... */ }
}
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;
}
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">📡</div>
}
@case ('server') {
<div class="icon server-icon">🔧</div>
}
@case ('validation') {
<div class="icon validation-icon">⚠️</div>
}
@default {
<div class="icon default-icon">🛡</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()}`;
}
}
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();
}
}
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)}`;
}
}
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([]);
}
}
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' });
});
});
});
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) | |
| +-------------+ +-------------+ +---------------------+ |
+-------------------------------------------------------------+
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
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. |
+-----------------+
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
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>(...);
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
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
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
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
}
}
}
}
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
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:
- Components recover UI state with Signals, Resource API, and fallback components
- Services define retry policies with exponential backoff and circuit breakers
- Interceptors standardize transport handling, auth, and correlation IDs
- Global ErrorHandler captures the unexpected and reports crashes
- 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
- Angular ErrorHandler Documentation
- Angular HttpInterceptorFn Guide
- Angular Signals & Resource API
- Sentry Angular Integration
- Azure Application Insights for Web
- OpenTelemetry Web SDK
- Google SRE Book - Handling Overload
What's the biggest improvement you've made to your application's error-handling architecture? Drop a comment below.
Top comments (0)