UTA DevHub

Error Handling Architecture

Comprehensive error handling patterns and custom error classes for the application

Error Handling Architecture

Overview

This document defines our error handling strategy, including custom error classes, error propagation patterns, and user-friendly error messages. Consistent error handling improves debugging, user experience, and app reliability. Our approach implements a hierarchical class structure with propagation mechanisms to ensure errors are caught, processed, and presented appropriately throughout the application.

Purpose & Scope

This guide outlines:

  • The custom error class hierarchy used across the application.
  • Standardized error codes and HTTP status mappings.
  • Patterns for handling and transforming errors at different layers (API, Domain, UI).
  • Best practices for logging, monitoring, and displaying errors to users.

Prerequisites

To effectively understand and implement the error handling patterns described here, readers should be familiar with:

  • JavaScript Error Handling: Fundamentals of Error objects, try...catch blocks, and error propagation in JavaScript/TypeScript.
  • HTTP Status Codes: A basic understanding of common HTTP status codes and their meanings (e.g., 2xx, 4xx, 5xx series).
  • API Client (Recommended): Familiarity with the project's API Client Architecture can be helpful to understand how API errors are initially caught and processed, especially regarding the handleApiError utility.
  • Logging/Monitoring Tools (Contextual): While not mandatory for understanding the error classes, awareness of any project-specific logging or crash reporting tools (e.g., Crashlytics, Sentry) will provide context for the error logging examples.

Constants Organization

Our error handling system relies on a structured set of constants for error codes, HTTP status codes, and API configurations. These are typically organized as follows:

index.ts serves as the barrel file for exporting all constants from this module.

// core/shared/constants/index.ts
export * from './httpStatusCodes';
export * from './errorCodes';
export * from './apiConstants';

Status Code Constants

// core/shared/constants/httpStatusCodes.ts
export const HTTP_STATUS = {
  // Success responses
  OK: 200,
  CREATED: 201,
  ACCEPTED: 202,
  NO_CONTENT: 204,
  
  // Redirection messages
  MOVED_PERMANENTLY: 301,
  FOUND: 302,
  NOT_MODIFIED: 304,
  
  // Client error responses
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  METHOD_NOT_ALLOWED: 405,
  CONFLICT: 409,
  UNPROCESSABLE_ENTITY: 422,
  TOO_MANY_REQUESTS: 429,
  
  // Server error responses
  INTERNAL_SERVER_ERROR: 500,
  NOT_IMPLEMENTED: 501,
  BAD_GATEWAY: 502,
  SERVICE_UNAVAILABLE: 503,
  GATEWAY_TIMEOUT: 504,
} as const;
 
export type HttpStatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
 
// Semantic aliases for better readability
export const API_STATUS = {
  SUCCESS: HTTP_STATUS.OK,
  CREATED: HTTP_STATUS.CREATED,
  INVALID_REQUEST: HTTP_STATUS.BAD_REQUEST,
  NEEDS_AUTH: HTTP_STATUS.UNAUTHORIZED,
  ACCESS_DENIED: HTTP_STATUS.FORBIDDEN,
  NOT_FOUND: HTTP_STATUS.NOT_FOUND,
  DUPLICATE: HTTP_STATUS.CONFLICT,
  VALIDATION_ERROR: HTTP_STATUS.UNPROCESSABLE_ENTITY,
  RATE_LIMITED: HTTP_STATUS.TOO_MANY_REQUESTS,
  SERVER_ERROR: HTTP_STATUS.INTERNAL_SERVER_ERROR,
  MAINTENANCE: HTTP_STATUS.SERVICE_UNAVAILABLE,
} as const;
 
// Helper functions
export const isSuccessStatus = (status: number): boolean => 
  status >= 200 && status < 300;
 
export const isClientError = (status: number): boolean => 
  status >= 400 && status < 500;
 
export const isServerError = (status: number): boolean => 
  status >= 500;

Error Class Hierarchy

Base Error Class

// core/shared/errors/ExtendableError.ts
export class ExtendableError extends Error {
  public readonly timestamp: Date;
  public readonly code: string;
  public readonly statusCode?: number;
  public readonly data?: any;
 
  constructor(
    message: string,
    code: string,
    statusCode?: number,
    data?: any
  ) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
    this.statusCode = statusCode;
    this.data = data;
    this.timestamp = new Date();
 
    // Maintains proper stack trace for where error was thrown
    Error.captureStackTrace(this, this.constructor);
  }
 
  toJSON() {
    return {
      name: this.name,
      message: this.message,
      code: this.code,
      statusCode: this.statusCode,
      data: this.data,
      timestamp: this.timestamp,
      stack: this.stack,
    };
  }
}

Network Error Classes

No Internet Connection Error

// core/shared/errors/NoInternetConnectionError.ts
import { ExtendableError } from './ExtendableError';
 
export class NoInternetConnectionError extends ExtendableError {
  constructor(message: string = 'No internet connection available') {
    super(message, 'NO_INTERNET_CONNECTION');
  }
}

Request Timeout Error

// core/shared/errors/RequestTimeoutError.ts
import { ExtendableError } from './ExtendableError';
import { HTTP_STATUS } from '@/core/shared/constants/httpStatusCodes';
 
export class RequestTimeoutError extends ExtendableError {
  constructor(
    url: string,
    timeout: number,
    message?: string
  ) {
    super(
      message || `Request to ${url} timed out after ${timeout}ms`,
      'REQUEST_TIMEOUT',
      HTTP_STATUS.REQUEST_TIMEOUT
    );
  }
}

API Response Errors

// core/shared/errors/ApiResponseError.ts
import { ExtendableError } from './ExtendableError';
 
export class ApiResponseError extends ExtendableError {
  constructor(
    message: string,
    statusCode: number,
    endpoint: string,
    responseData?: any
  ) {
    super(
      message,
      'API_RESPONSE_ERROR',
      statusCode,
      { endpoint, responseData }
    );
  }
}
 
// Specific API response errors
export class ResponseMaintenanceModeError extends ApiResponseError {
  constructor(endpoint: string, retryAfter?: number) {
    super(
      'Service is currently under maintenance',
      503,
      endpoint,
      { retryAfter }
    );
  }
}
 
export class ResponseRateLimitError extends ApiResponseError {
  constructor(endpoint: string, retryAfter: number) {
    super(
      'Rate limit exceeded',
      429,
      endpoint,
      { retryAfter }
    );
  }
}
 
export class ResponseNotFoundError extends ApiResponseError {
  constructor(resource: string, id?: string) {
    super(
      `${resource} not found`,
      404,
      resource,
      { id }
    );
  }
}

Authentication Error Classes

// core/shared/errors/AuthenticationError.ts
import { ExtendableError } from './ExtendableError';
import { HTTP_STATUS } from '@/core/shared/constants/httpStatusCodes';
 
export class AuthenticationError extends ExtendableError {
  constructor(
    message: string,
    code: string = 'AUTH_ERROR',
    statusCode: number = HTTP_STATUS.UNAUTHORIZED
  ) {
    super(message, code, statusCode);
  }
}
 
export class RefreshTokenExpiredError extends AuthenticationError {
  constructor() {
    super(
      'Refresh token has expired. Please login again.',
      'REFRESH_TOKEN_EXPIRED',
      HTTP_STATUS.UNAUTHORIZED
    );
  }
}
 
export class InvalidCredentialsError extends AuthenticationError {
  constructor() {
    super(
      'Invalid email or password',
      'INVALID_CREDENTIALS',
      HTTP_STATUS.UNAUTHORIZED
    );
  }
}
 
export class SessionExpiredError extends AuthenticationError {
  constructor() {
    super(
      'Your session has expired. Please login again.',
      'SESSION_EXPIRED',
      HTTP_STATUS.UNAUTHORIZED
    );
  }
}
 
export class UnauthorizedError extends AuthenticationError {
  constructor(resource?: string) {
    super(
      `You don't have permission to access ${resource || 'this resource'}`,
      'UNAUTHORIZED',
      HTTP_STATUS.FORBIDDEN
    );
  }
}

Validation Error Classes

// core/shared/errors/ValidationError.ts
import { ExtendableError } from './ExtendableError';
import { HTTP_STATUS } from '@/core/shared/constants/httpStatusCodes';
 
export interface ValidationErrorField {
  field: string;
  message: string;
  value?: any;
}
 
export class ValidationError extends ExtendableError {
  public readonly fields: ValidationErrorField[];
 
  constructor(
    message: string = 'Validation failed',
    fields: ValidationErrorField[] = []
  ) {
    super(message, 'VALIDATION_ERROR', HTTP_STATUS.BAD_REQUEST, { fields });
    this.fields = fields;
  }
 
  addFieldError(field: string, message: string, value?: any) {
    this.fields.push({ field, message, value });
  }
 
  hasFieldError(field: string): boolean {
    return this.fields.some(f => f.field === field);
  }
 
  getFieldError(field: string): ValidationErrorField | undefined {
    return this.fields.find(f => f.field === field);
  }
}
 
export class FormValidationError extends ValidationError {
  constructor(fields: ValidationErrorField[]) {
    super('Please correct the errors in the form', fields);
  }
}

Business Logic Errors

// core/shared/errors/BusinessLogicError.ts
import { ExtendableError } from './ExtendableError';
import { HTTP_STATUS } from '@/core/shared/constants/httpStatusCodes';
 
export class BusinessLogicError extends ExtendableError {
  constructor(
    message: string,
    code: string,
    data?: any
  ) {
    super(message, code, HTTP_STATUS.UNPROCESSABLE_ENTITY, data);
  }
}
 
export class InsufficientBalanceError extends BusinessLogicError {
  constructor(required: number, available: number) {
    super(
      `Insufficient balance. Required: ${required}, Available: ${available}`,
      'INSUFFICIENT_BALANCE',
      { required, available }
    );
  }
}
 
export class DuplicateResourceError extends BusinessLogicError {
  constructor(resource: string, field: string, value: any) {
    super(
      `${resource} with ${field} '${value}' already exists`,
      'DUPLICATE_RESOURCE',
      { resource, field, value }
    );
  }
}
 
export class ResourceLockedError extends BusinessLogicError {
  constructor(resource: string, reason?: string) {
    super(
      `${resource} is currently locked${reason ? `: ${reason}` : ''}`,
      'RESOURCE_LOCKED',
      { resource, reason }
    );
  }
}

Storage Errors

// core/shared/errors/StorageError.ts
import { ExtendableError } from './ExtendableError';
 
export class StorageError extends ExtendableError {
  constructor(
    message: string,
    code: string = 'STORAGE_ERROR',
    data?: any
  ) {
    super(message, code, undefined, data);
  }
}
 
export class StorageQuotaExceededError extends StorageError {
  constructor(required: number, available: number) {
    super(
      'Storage quota exceeded',
      'STORAGE_QUOTA_EXCEEDED',
      { required, available }
    );
  }
}
 
export class StoragePermissionDeniedError extends StorageError {
  constructor() {
    super(
      'Storage permission denied',
      'STORAGE_PERMISSION_DENIED'
    );
  }
}

Error Handling Flow

Error Handling in API Client

// core/shared/api/errorHandler.ts
import { AxiosError } from 'axios';
import { HTTP_STATUS } from '@/core/shared/constants/httpStatusCodes';
import {
  NoInternetConnectionError,
  RequestTimeoutError,
  ResponseMaintenanceModeError,
  ResponseRateLimitError,
  RefreshTokenExpiredError,
  UnauthorizedError,
  ValidationError,
  ApiResponseError,
} from '@/core/shared/errors';
 
export function handleApiError(error: AxiosError): never {
  // No internet connection
  if (!error.response && error.code === 'NETWORK_ERROR') {
    throw new NoInternetConnectionError();
  }
 
  // Request timeout
  if (error.code === 'ECONNABORTED') {
    throw new RequestTimeoutError(
      error.config?.url || 'Unknown',
      error.config?.timeout || 30000
    );
  }
 
  // Handle specific response status codes
  if (error.response) {
    const { status, data } = error.response;
    const endpoint = error.config?.url || 'Unknown';
 
    switch (status) {
      case HTTP_STATUS.BAD_REQUEST:
        if (data.errors) {
          throw new ValidationError('Validation failed', data.errors);
        }
        break;
 
      case HTTP_STATUS.UNAUTHORIZED:
        if (data.code === 'REFRESH_TOKEN_EXPIRED') {
          throw new RefreshTokenExpiredError();
        }
        throw new UnauthorizedError();
 
      case HTTP_STATUS.FORBIDDEN:
        throw new UnauthorizedError(data.resource);
 
      case HTTP_STATUS.NOT_FOUND:
        throw new ResponseNotFoundError(data.resource, data.id);
 
      case HTTP_STATUS.TOO_MANY_REQUESTS:
        throw new ResponseRateLimitError(endpoint, data.retryAfter);
 
      case HTTP_STATUS.SERVICE_UNAVAILABLE:
        throw new ResponseMaintenanceModeError(endpoint, data.retryAfter);
 
      default:
        throw new ApiResponseError(
          data.message || 'An error occurred',
          status,
          endpoint,
          data
        );
    }
  }
 
  // Default error
  throw error;
}

Error Handling in Domain APIs

// core/domains/products/api.ts
import { handleApiError } from '@/core/shared/api/errorHandler';
import { DuplicateResourceError } from '@/core/shared/errors';
import { HTTP_STATUS } from '@/core/shared/constants/httpStatusCodes';
 
class ProductApi {
  public readonly protected = {
    create: async (product: CreateProductDto): Promise<Product> => {
      try {
        return await authenticatedApi.post('/products', product);
      } catch (error) {
        // Handle domain-specific errors
        if (error.response?.status === HTTP_STATUS.CONFLICT) {
          throw new DuplicateResourceError(
            'Product',
            'name',
            product.name
          );
        }
        
        // Use centralized error handler for other errors
        return handleApiError(error);
      }
    },
  };
}

Error Handling in UI Components

// features/products/screens/CreateProductScreen.tsx
import { useCreateProduct } from '@/core/domains/products/hooks';
import {
  ValidationError,
  DuplicateResourceError,
  NoInternetConnectionError,
} from '@/core/shared/errors';
 
export function CreateProductScreen() {
  const createProduct = useCreateProduct();
  const [errors, setErrors] = useState<Record<string, string>>({});
 
  const handleSubmit = async (data: ProductFormData) => {
    try {
      await createProduct.mutateAsync(data);
      navigation.goBack();
    } catch (error) {
      if (error instanceof ValidationError) {
        // Show field-specific errors
        const fieldErrors: Record<string, string> = {};
        error.fields.forEach(field => {
          fieldErrors[field.field] = field.message;
        });
        setErrors(fieldErrors);
      } else if (error instanceof DuplicateResourceError) {
        Alert.alert(
          'Product Already Exists',
          'A product with this name already exists.'
        );
      } else if (error instanceof NoInternetConnectionError) {
        Alert.alert(
          'No Internet',
          'Please check your internet connection and try again.'
        );
      } else {
        // Generic error handling
        Alert.alert(
          'Error',
          error.message || 'An unexpected error occurred'
        );
      }
    }
  };
 
  return (
    <Form onSubmit={handleSubmit}>
      <TextInput
        label="Product Name"
        error={errors.name}
        onChangeText={() => setErrors({ ...errors, name: undefined })}
      />
      {/* Other form fields */}
    </Form>
  );
}

Global Error Boundary

// App.tsx
import { ErrorBoundary } from 'react-error-boundary';
import { ExtendableError } from '@/core/shared/errors';
 
function ErrorFallback({ error, resetErrorBoundary }) {
  // Log error to crash reporting service
  if (__DEV__) {
    console.error('Error caught by boundary:', error);
  } else {
    crashlytics().recordError(error);
  }
 
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Oops! Something went wrong</Text>
      <Text style={styles.message}>
        {error instanceof ExtendableError
          ? error.message
          : 'An unexpected error occurred'}
      </Text>
      <Button title="Try Again" onPress={resetErrorBoundary} />
    </View>
  );
}
 
export function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onReset={() => {
        // Reset app state if needed
      }}
    >
      <AppContent />
    </ErrorBoundary>
  );
}

Error Logging and Monitoring

// core/shared/services/errorLogger.ts
import crashlytics from '@react-native-firebase/crashlytics';
import analytics from '@react-native-firebase/analytics';
import { ExtendableError } from '@/core/shared/errors';
 
export class ErrorLogger {
  static logError(error: Error, context?: Record<string, any>) {
    if (__DEV__) {
      console.error('Error:', error, context);
      return;
    }
 
    // Log to crash reporting
    if (error instanceof ExtendableError) {
      crashlytics().recordError(error, {
        code: error.code,
        statusCode: error.statusCode,
        ...context,
      });
    } else {
      crashlytics().recordError(error);
    }
 
    // Log to analytics
    analytics().logEvent('error_occurred', {
      error_type: error.name,
      error_message: error.message,
      ...context,
    });
  }
 
  static logApiError(
    error: ExtendableError,
    endpoint: string,
    method: string
  ) {
    this.logError(error, {
      endpoint,
      method,
      api_error: true,
    });
  }
}

Error Message Localization

// core/shared/errors/errorMessages.ts
export const ERROR_MESSAGES = {
  NO_INTERNET_CONNECTION: {
    en: 'No internet connection available',
    es: 'No hay conexión a internet disponible',
    fr: 'Aucune connexion internet disponible',
  },
  SESSION_EXPIRED: {
    en: 'Your session has expired. Please login again.',
    es: 'Tu sesión ha expirado. Por favor inicia sesión nuevamente.',
    fr: 'Votre session a expiré. Veuillez vous reconnecter.',
  },
  // ... other messages
};
 
// Helper to get localized message
export function getErrorMessage(
  code: string,
  locale: string = 'en'
): string {
  return ERROR_MESSAGES[code]?.[locale] || ERROR_MESSAGES[code]?.en || code;
}

Testing Error Scenarios

Note: These error testing strategies integrate with our broader testing approach outlined in the testing document. The error tests focus on unit testing individual error classes and integration testing error handling mechanisms, complementing the end-to-end tests that verify complete user flows during error conditions.

// core/shared/errors/__tests__/errors.test.ts
describe('Error Classes', () => {
  it('should create NoInternetConnectionError correctly', () => {
    const error = new NoInternetConnectionError();
    
    expect(error.code).toBe('NO_INTERNET_CONNECTION');
    expect(error.message).toBe('Unable to connect to the internet');
    expect(error.timestamp instanceof Date).toBeTruthy();
  });
  
  it('should test ApiResponseError with error details', () => {
    const error = new ApiResponseError(
      'Bad Request', 
      400, 
      '/users', 
      { field: 'email', message: 'Invalid format' }
    );
    
    expect(error.code).toBe('API_RESPONSE_ERROR');
    expect(error.statusCode).toBe(400);
    expect(error.data).toBeDefined();
  });
  
  it('should test FormValidationError fields access', () => {
    const error = new FormValidationError([
      { field: 'email', message: 'Invalid email format' },
      { field: 'password', message: 'Password too short' }
    ]);
    
    expect(error.getFieldError('email')?.message).toBe('Invalid email format');
  });
});
 
// Test error handling in API
describe('API Error Handling', () => {
  it('should throw NoInternetConnectionError on network failure', async () => {
    const axiosError = new AxiosError('Network Error');
    axiosError.code = 'NETWORK_ERROR';
    
    expect(() => handleApiError(axiosError))
      .toThrow(NoInternetConnectionError);
  });
});

Best Practices

To maintain a robust and understandable error handling system, we recommend adhering to the following best practices:

  1. (Do ✅) Use Specific Error Classes: Create and use distinct error classes that inherit from ExtendableError for different categories of errors (e.g., NetworkError, AuthenticationError, ValidationError). This enables more precise instanceof checks, targeted error handling logic, and clearer intent in your code.
  2. (Do ✅) Include Rich Context: When throwing or logging errors, ensure they carry as much relevant context as possible (e.g., statusCode, errorCode, endpoint, relevant data). This significantly aids in debugging and understanding the root cause of issues.
  3. (Do ✅) Provide User-Friendly Messages: For errors that will be displayed to the user, craft messages that are clear, concise, and actionable, guiding them on what to do next or what went wrong in understandable terms. Avoid exposing raw technical error details directly to users.
  4. (Do ✅) Log Errors Comprehensively: Implement thorough error logging with sufficient context (as per point 2) at appropriate layers. This is crucial for monitoring application health, identifying patterns, and diagnosing problems in development and production.
  5. (Do ✅) Handle Errors Gracefully: Always ensure there are fallback mechanisms (like Error Boundaries in UI) to catch unexpected errors and prevent application crashes, providing a more stable user experience.
  6. (Do ✅) Test Error Paths Thoroughly: Write unit and integration tests specifically for error scenarios to verify that your error handling logic behaves as expected under various failure conditions.
  7. (Do ✅) Localize User-Facing Error Messages: If your application supports multiple languages, ensure that error messages displayed to users are properly localized for a better user experience globally.

Design Principles

Core Architectural Principles

  1. Hierarchical Organization

    • Error classes follow a clear, logical inheritance hierarchy
    • Common behavior implemented in base classes
    • Specific behavior in specialized classes
  2. Contextual Information

    • Errors carry detailed context about what went wrong
    • Status codes mapped to semantic error types
    • Supporting data attached to aid debugging
  3. Error Propagation

    • Errors are transformed at system boundaries
    • Domain-specific errors remain isolated to their domains
    • Central error handling manages common error patterns

Trade-offs and Design Decisions

DecisionBenefitsDrawbacksRationale
Custom error classesType-safe handling, rich contextMore code to maintainEnables precise error handling patterns and better debugging
Centralized error handlerConsistent handling, DRYPotential single point of failureEnsures consistent error transformation across the application
Error boundaries at component levelGraceful recovery, isolationMore complex component structurePrevents entire app crashes due to UI errors
Typed error codesStatic analysis, auto-completionOverhead in maintenanceMakes error handling more robust and less prone to typos

Constraints and Considerations

  • All error messages must be localizable
  • Error handling must work in offline scenarios
  • Performance impact of try/catch blocks must be considered
  • Sensitive information must never appear in error messages or logs

Implementation Considerations

Performance Implications

  • Error Creation: Creating error objects with full stack traces can be expensive; optimize for production
  • Try/Catch Blocks: Excessive try/catch can affect JavaScript engine optimization
  • Error Transformation: Complex transformations should be avoided in critical paths
  • Logging: Asynchronous logging prevents UI thread blocking

Security Considerations

  • Sensitive Data: Never include auth tokens, passwords or PII in error messages or logs
  • User Messages: External-facing error messages should be sanitized
  • Stack Traces: Never expose stack traces to end users
  • Input Validation: Validate all inputs to prevent injection attacks through error messages

Scalability Aspects

  • Error Categories: The system supports adding new error categories without modifying core logic
  • Error Routing: Error handling architecture supports routing errors to different handlers
  • Domain Isolation: Domain-specific errors don't leak across boundaries
  • Monitoring: Error tracking scales with automatically grouped similar errors

Summary

This comprehensive error handling architecture provides:

  • Clear error class hierarchy
  • Specific errors for different scenarios
  • Centralized error handling
  • User-friendly error messages
  • Proper error propagation
  • Error logging and monitoring
  • Localization support