WellNuo/services/errorHandler.ts
Sergei a238b7e35f Add comprehensive error handling system
- Add extended error types with severity levels, retry policies,
  and contextual information (types/errors.ts)
- Create centralized error handler service with user-friendly
  message translation and classification (services/errorHandler.ts)
- Add ErrorContext for global error state management with
  auto-dismiss and error queue support (contexts/ErrorContext.tsx)
- Create error UI components: ErrorToast, FieldError,
  FieldErrorSummary, FullScreenError, EmptyState, OfflineState
- Add useError hook with retry strategies and API response handling
- Add useAsync hook for async operations with comprehensive state
- Create error message utilities with validation helpers
- Add tests for errorHandler and errorMessages (88 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:43:07 -08:00

379 lines
13 KiB
TypeScript

/**
* Centralized Error Handler Service
*
* Provides error classification, user-friendly message translation,
* and recovery strategy recommendations.
*/
import {
AppError,
ErrorCategory,
ErrorCode,
ErrorCodes,
ErrorSeverity,
FieldError,
RetryPolicy,
DefaultRetryPolicies,
} from '@/types/errors';
import { ApiError } from '@/types';
// Generate unique error ID
function generateErrorId(): string {
return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// User-friendly messages for error codes
const userMessages: Record<string, string> = {
// Network
[ErrorCodes.NETWORK_ERROR]: 'Unable to connect. Please check your internet connection.',
[ErrorCodes.NETWORK_TIMEOUT]: 'The request timed out. Please try again.',
[ErrorCodes.NETWORK_OFFLINE]: 'You appear to be offline. Please check your connection.',
// Authentication
[ErrorCodes.UNAUTHORIZED]: 'Your session has expired. Please log in again.',
[ErrorCodes.TOKEN_EXPIRED]: 'Your session has expired. Please log in again.',
[ErrorCodes.INVALID_CREDENTIALS]: 'Invalid email or password.',
[ErrorCodes.SESSION_EXPIRED]: 'Your session has expired. Please log in again.',
// Permission
[ErrorCodes.FORBIDDEN]: 'You don\'t have permission to perform this action.',
[ErrorCodes.INSUFFICIENT_PERMISSIONS]: 'You don\'t have sufficient permissions.',
// Resource
[ErrorCodes.NOT_FOUND]: 'The requested resource was not found.',
[ErrorCodes.BENEFICIARY_NOT_FOUND]: 'Beneficiary not found.',
[ErrorCodes.DEPLOYMENT_NOT_FOUND]: 'Deployment not found.',
[ErrorCodes.USER_NOT_FOUND]: 'User not found.',
// Conflict
[ErrorCodes.CONFLICT]: 'A conflict occurred. Please refresh and try again.',
[ErrorCodes.DUPLICATE_ENTRY]: 'This entry already exists.',
[ErrorCodes.VERSION_MISMATCH]: 'Data has changed. Please refresh and try again.',
// Validation
[ErrorCodes.VALIDATION_ERROR]: 'Please check your input and try again.',
[ErrorCodes.INVALID_INPUT]: 'Invalid input. Please check and try again.',
[ErrorCodes.MISSING_REQUIRED_FIELD]: 'Please fill in all required fields.',
[ErrorCodes.INVALID_EMAIL]: 'Please enter a valid email address.',
[ErrorCodes.INVALID_PHONE]: 'Please enter a valid phone number.',
[ErrorCodes.INVALID_OTP]: 'Invalid verification code. Please try again.',
// Server
[ErrorCodes.SERVER_ERROR]: 'Something went wrong on our end. Please try again later.',
[ErrorCodes.SERVICE_UNAVAILABLE]: 'Service is temporarily unavailable. Please try again later.',
[ErrorCodes.MAINTENANCE]: 'We\'re currently performing maintenance. Please try again soon.',
// Rate limiting
[ErrorCodes.RATE_LIMITED]: 'Too many requests. Please wait a moment and try again.',
[ErrorCodes.TOO_MANY_REQUESTS]: 'Too many requests. Please wait a moment and try again.',
// BLE
[ErrorCodes.BLE_NOT_AVAILABLE]: 'Bluetooth is not available on this device.',
[ErrorCodes.BLE_NOT_ENABLED]: 'Please enable Bluetooth to continue.',
[ErrorCodes.BLE_PERMISSION_DENIED]: 'Bluetooth permission is required.',
[ErrorCodes.BLE_CONNECTION_FAILED]: 'Failed to connect to device. Please try again.',
[ErrorCodes.BLE_DEVICE_NOT_FOUND]: 'Device not found. Make sure it\'s powered on.',
[ErrorCodes.BLE_TIMEOUT]: 'Connection timed out. Please try again.',
// Sensor
[ErrorCodes.SENSOR_SETUP_FAILED]: 'Sensor setup failed. Please try again.',
[ErrorCodes.SENSOR_OFFLINE]: 'Sensor is offline. Check the device connection.',
[ErrorCodes.SENSOR_NOT_RESPONDING]: 'Sensor is not responding. Please try again.',
[ErrorCodes.SENSOR_WIFI_FAILED]: 'Failed to configure WiFi. Please check credentials.',
// Subscription
[ErrorCodes.SUBSCRIPTION_REQUIRED]: 'A subscription is required to access this feature.',
[ErrorCodes.SUBSCRIPTION_EXPIRED]: 'Your subscription has expired.',
[ErrorCodes.PAYMENT_FAILED]: 'Payment failed. Please try again or use a different method.',
[ErrorCodes.PAYMENT_CANCELED]: 'Payment was canceled.',
// General
[ErrorCodes.UNKNOWN_ERROR]: 'Something went wrong. Please try again.',
[ErrorCodes.API_ERROR]: 'An error occurred. Please try again.',
[ErrorCodes.PARSE_ERROR]: 'Failed to process response. Please try again.',
[ErrorCodes.EXCEPTION]: 'An unexpected error occurred.',
[ErrorCodes.NOT_AUTHENTICATED]: 'Please log in to continue.',
[ErrorCodes.NO_BENEFICIARY_SELECTED]: 'Please select a beneficiary.',
[ErrorCodes.NO_DEPLOYMENT]: 'No deployment configured.',
};
// Action hints for error codes
const actionHints: Record<string, string> = {
[ErrorCodes.NETWORK_ERROR]: 'Check your Wi-Fi or cellular connection',
[ErrorCodes.NETWORK_OFFLINE]: 'Connect to the internet to continue',
[ErrorCodes.UNAUTHORIZED]: 'Tap here to log in again',
[ErrorCodes.TOKEN_EXPIRED]: 'Tap here to log in again',
[ErrorCodes.BLE_NOT_ENABLED]: 'Enable Bluetooth in Settings',
[ErrorCodes.BLE_PERMISSION_DENIED]: 'Grant Bluetooth permission in Settings',
[ErrorCodes.SUBSCRIPTION_REQUIRED]: 'View subscription options',
[ErrorCodes.SUBSCRIPTION_EXPIRED]: 'Renew your subscription',
[ErrorCodes.PAYMENT_FAILED]: 'Try a different payment method',
};
// Classify HTTP status to error category
function classifyHttpStatus(status: number): ErrorCategory {
if (status === 401) return 'authentication';
if (status === 403) return 'permission';
if (status === 404) return 'notFound';
if (status === 409) return 'conflict';
if (status === 429) return 'rateLimit';
if (status === 422 || status === 400) return 'validation';
if (status >= 500) return 'server';
if (status >= 400) return 'client';
return 'unknown';
}
// Classify error code to category
function classifyErrorCode(code: string): ErrorCategory {
// Check timeout first before network (NETWORK_TIMEOUT should be timeout, not network)
if (code.includes('TIMEOUT')) return 'timeout';
if (code.startsWith('NETWORK') || code === 'NETWORK_ERROR') return 'network';
if (code.includes('AUTH') || code.includes('TOKEN') || code.includes('SESSION') || code === 'UNAUTHORIZED') {
return 'authentication';
}
if (code.includes('PERMISSION') || code === 'FORBIDDEN') return 'permission';
if (code.includes('NOT_FOUND')) return 'notFound';
if (code.includes('CONFLICT') || code.includes('DUPLICATE') || code.includes('MISMATCH')) return 'conflict';
if (code.includes('VALIDATION') || code.includes('INVALID') || code.includes('MISSING')) return 'validation';
if (code.includes('RATE') || code.includes('TOO_MANY')) return 'rateLimit';
if (code.includes('SERVER') || code.includes('UNAVAILABLE') || code.includes('MAINTENANCE')) return 'server';
if (code.startsWith('BLE')) return 'ble';
if (code.startsWith('SENSOR')) return 'sensor';
if (code.includes('SUBSCRIPTION') || code.includes('PAYMENT')) return 'subscription';
return 'unknown';
}
// Determine severity from category and status
function determineSeverity(category: ErrorCategory, status?: number): ErrorSeverity {
// Critical - app cannot continue
if (category === 'authentication') return 'error';
if (status && status >= 500) return 'error';
if (category === 'server') return 'error';
// Warning - recoverable
if (category === 'network' || category === 'timeout') return 'warning';
if (category === 'rateLimit') return 'warning';
// Error - operation failed
if (category === 'validation' || category === 'permission' || category === 'notFound') return 'error';
// Default to error
return 'error';
}
// Get user message for error
function getUserMessage(code: string, message?: string): string {
// Use predefined message if available
if (userMessages[code]) {
return userMessages[code];
}
// Fall back to provided message or generic
return message || 'Something went wrong. Please try again.';
}
// Get action hint for error
function getActionHint(code: string): string | undefined {
return actionHints[code];
}
// Get retry policy for category
function getRetryPolicy(category: ErrorCategory): RetryPolicy {
return DefaultRetryPolicies[category] || DefaultRetryPolicies.unknown;
}
/**
* Create AppError from API error
*/
export function createErrorFromApi(apiError: ApiError, context?: Record<string, unknown>): AppError {
const code = apiError.code || ErrorCodes.API_ERROR;
const status = apiError.status;
const category = status ? classifyHttpStatus(status) : classifyErrorCode(code);
const severity = determineSeverity(category, status);
return {
message: apiError.message,
code,
severity,
category,
status,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code, apiError.message),
actionHint: getActionHint(code),
context,
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Create AppError from JavaScript Error
*/
export function createErrorFromException(
error: Error,
code: string = ErrorCodes.EXCEPTION,
context?: Record<string, unknown>
): AppError {
const category = classifyErrorCode(code);
const severity = determineSeverity(category);
return {
message: error.message,
code,
severity,
category,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code, error.message),
actionHint: getActionHint(code),
context,
timestamp: new Date(),
errorId: generateErrorId(),
originalError: error,
};
}
/**
* Create AppError from network error
*/
export function createNetworkError(
message: string = 'Network request failed',
isTimeout: boolean = false
): AppError {
const code = isTimeout ? ErrorCodes.NETWORK_TIMEOUT : ErrorCodes.NETWORK_ERROR;
const category: ErrorCategory = isTimeout ? 'timeout' : 'network';
return {
message,
code,
severity: 'warning',
category,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code),
actionHint: getActionHint(code),
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Create AppError for validation with field errors
*/
export function createValidationError(
message: string,
fieldErrors: FieldError[] = []
): AppError {
return {
message,
code: ErrorCodes.VALIDATION_ERROR,
severity: 'error',
category: 'validation',
retry: { isRetryable: false },
userMessage: getUserMessage(ErrorCodes.VALIDATION_ERROR, message),
fieldErrors,
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Create AppError from any unknown error
*/
export function createErrorFromUnknown(
error: unknown,
defaultCode: string = ErrorCodes.UNKNOWN_ERROR,
context?: Record<string, unknown>
): AppError {
// Already an AppError
if (error && typeof error === 'object' && 'errorId' in error) {
return error as AppError;
}
// JavaScript Error
if (error instanceof Error) {
return createErrorFromException(error, defaultCode, context);
}
// ApiError-like object
if (error && typeof error === 'object' && 'message' in error) {
return createErrorFromApi(error as ApiError, context);
}
// String error
if (typeof error === 'string') {
return createErrorFromException(new Error(error), defaultCode, context);
}
// Unknown
return {
message: 'An unknown error occurred',
code: defaultCode,
severity: 'error',
category: 'unknown',
retry: { isRetryable: false },
userMessage: getUserMessage(defaultCode),
timestamp: new Date(),
errorId: generateErrorId(),
originalError: error,
context,
};
}
/**
* Create a simple error with predefined code
*/
export function createError(
code: ErrorCode,
message?: string,
context?: Record<string, unknown>
): AppError {
const category = classifyErrorCode(code);
const severity = determineSeverity(category);
return {
message: message || getUserMessage(code),
code,
severity,
category,
retry: getRetryPolicy(category),
userMessage: getUserMessage(code, message),
actionHint: getActionHint(code),
context,
timestamp: new Date(),
errorId: generateErrorId(),
};
}
/**
* Calculate retry delay with optional exponential backoff
*/
export function calculateRetryDelay(policy: RetryPolicy, attempt: number): number {
if (!policy.retryDelayMs) return 1000;
if (policy.useExponentialBackoff) {
// Exponential backoff: delay * 2^attempt with jitter
const baseDelay = policy.retryDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * 0.3 * baseDelay; // 0-30% jitter
return Math.min(baseDelay + jitter, 30000); // Max 30 seconds
}
return policy.retryDelayMs;
}
/**
* Log error for debugging/analytics (placeholder for future integration)
*/
export function logError(error: AppError): void {
if (__DEV__) {
console.error(`[${error.severity.toUpperCase()}] ${error.code}:`, error.message, {
category: error.category,
errorId: error.errorId,
context: error.context,
});
}
// TODO: Integrate with error tracking service (e.g., Sentry, Bugsnag)
}
// Export error codes for convenience
export { ErrorCodes };