- 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>
379 lines
13 KiB
TypeScript
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 };
|