/** * Error Messages Utility * FE-API-013: Centralized user-friendly error messages * * Provides consistent, user-friendly error messages across the application * with support for internationalization and context-aware messages. */ import type { ApiError } from '@/schemas/apiSchemas'; /** * User-friendly error messages mapped by HTTP status codes */ export const ERROR_MESSAGES = { // Client errors (4xx) 400: 'Invalid request. Please check the information provided.', 401: 'You must be logged in to perform this action.', 403: 'You do not have permission to perform this action.', 404: 'The requested resource was not found.', 409: 'A conflict occurred. This resource already exists or has been modified.', 422: 'The data provided is not valid.', 429: 'Too many requests. Please wait a moment before trying again.', // Server errors (5xx) 500: 'A server error occurred. Please try again later.', 502: 'Server communication error. Please try again later.', 503: 'Service temporarily unavailable. Please try again in a moment.', 504: 'The server is taking too long to respond. Please try again later.', // Network errors NETWORK: 'Connection error. Check your internet connection and try again. If the problem persists, the server may be temporarily unavailable.', TIMEOUT: 'The request timed out. Check your internet connection and try again.', UNKNOWN: 'An unexpected error occurred. Please try again.', } as const; /** * Context-specific error messages */ export const CONTEXT_ERROR_MESSAGES = { auth: { login: 'Login failed. Check your credentials.', logout: 'Error during logout.', register: 'Error during registration. Please try again.', tokenExpired: 'Your session has expired. Please log in again.', }, upload: { fileTooLarge: 'The file is too large.', invalidFormat: 'The file format is not supported.', uploadFailed: 'Upload failed. Please try again.', networkError: 'Network error during upload. Check your connection.', }, playlist: { notFound: 'Playlist not found.', accessDenied: 'You do not have access to this playlist.', createFailed: 'Error creating the playlist.', updateFailed: 'Error updating the playlist.', deleteFailed: 'Error deleting the playlist.', }, track: { notFound: 'Track not found.', playFailed: 'Unable to play track. Check your connection.', uploadFailed: 'Error uploading the track.', deleteFailed: 'Error deleting the track.', }, conversation: { notFound: 'Conversation not found.', accessDenied: 'You do not have access to this conversation.', createFailed: 'Error creating the conversation.', sendMessageFailed: 'Error sending the message.', }, search: { failed: 'Search failed. Please try again.', timeout: 'Search took too long. Please try again.', invalidQuery: 'Invalid search query.', }, } as const; /** * Gets a user-friendly error message based on status code * @param status HTTP status code * @param defaultMessage Optional default message if status not found * @returns User-friendly error message */ export function getErrorMessageByStatus( status: number, defaultMessage?: string, ): string { if (status in ERROR_MESSAGES) { return ERROR_MESSAGES[status as keyof typeof ERROR_MESSAGES]; } return defaultMessage || ERROR_MESSAGES.UNKNOWN; } /** * Gets a context-specific error message * @param context Context key (e.g., 'auth', 'upload') * @param action Action key (e.g., 'login', 'uploadFailed') * @param defaultMessage Optional default message if context/action not found * @returns Context-specific error message */ export function getContextErrorMessage( context: keyof typeof CONTEXT_ERROR_MESSAGES, action: string, defaultMessage?: string, ): string { const contextMessages = CONTEXT_ERROR_MESSAGES[context]; if (contextMessages && action in contextMessages) { return (contextMessages as any)[action]; } return defaultMessage || ERROR_MESSAGES.UNKNOWN; } /** * Formats an ApiError into a user-friendly message * @param error ApiError object * @param context Optional context for context-specific messages * @param includeDetails Whether to include validation details in the message * @returns Formatted user-friendly error message */ export function formatUserFriendlyError( error: ApiError | Error | unknown, context?: keyof typeof CONTEXT_ERROR_MESSAGES, includeDetails: boolean = false, ): string { // Handle ApiError if ( error && typeof error === 'object' && 'code' in error && 'message' in error ) { const apiError = error as ApiError; const status = typeof apiError.code === 'number' ? apiError.code : 0; // Try context-specific message first if (context && status >= 400 && status < 500) { // Try to extract action from error message or use status const action = extractActionFromMessage(apiError.message); const contextMessage = getContextErrorMessage(context, action, undefined); if (contextMessage !== ERROR_MESSAGES.UNKNOWN) { return contextMessage; } } // Use status-based message if (status > 0) { const statusMessage = getErrorMessageByStatus(status, apiError.message); // Add validation details if requested if ( includeDetails && apiError.details && Array.isArray(apiError.details) ) { const details = apiError.details .map((d: { message?: string; field?: string }) => d.message || d.field) .filter(Boolean) .join(', '); if (details) { return `${statusMessage} (${details})`; } } return statusMessage; } // Fallback to error message return apiError.message || ERROR_MESSAGES.UNKNOWN; } // Handle standard Error if (error instanceof Error) { return error.message || ERROR_MESSAGES.UNKNOWN; } // Handle network errors if (error && typeof error === 'object' && 'code' in error) { const code = (error as any).code; if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { return ERROR_MESSAGES.TIMEOUT; } if (code === 'ERR_NETWORK' || !(error as any).response) { return ERROR_MESSAGES.NETWORK; } } return ERROR_MESSAGES.UNKNOWN; } /** * Extracts action from error message for context matching * @param message Error message * @returns Extracted action key */ function extractActionFromMessage(message: string): string { const lowerMessage = message.toLowerCase(); if (lowerMessage.includes('login') || lowerMessage.includes('connexion')) { return 'login'; } if (lowerMessage.includes('logout') || lowerMessage.includes('déconnexion')) { return 'logout'; } if ( lowerMessage.includes('register') || lowerMessage.includes('inscription') ) { return 'register'; } if ( lowerMessage.includes('upload') || lowerMessage.includes('téléchargement') ) { if (lowerMessage.includes('large') || lowerMessage.includes('volumineux')) { return 'fileTooLarge'; } if (lowerMessage.includes('format') || lowerMessage.includes('type')) { return 'invalidFormat'; } return 'uploadFailed'; } if ( lowerMessage.includes('not found') || lowerMessage.includes('introuvable') ) { return 'notFound'; } if ( lowerMessage.includes('access denied') || lowerMessage.includes('permission') ) { return 'accessDenied'; } if (lowerMessage.includes('create') || lowerMessage.includes('créer')) { return 'createFailed'; } if (lowerMessage.includes('update') || lowerMessage.includes('mise à jour')) { return 'updateFailed'; } if (lowerMessage.includes('delete') || lowerMessage.includes('suppression')) { return 'deleteFailed'; } return ''; } /** * Checks if an error is retryable * @param error Error object * @returns True if the error is retryable */ export function isRetryableError(error: unknown): boolean { if (error && typeof error === 'object' && 'code' in error) { const apiError = error as ApiError; const status = typeof apiError.code === 'number' ? apiError.code : 0; // Retryable status codes: 429, 500, 502, 503, 504 if ([429, 500, 502, 503, 504].includes(status)) { return true; } // Network errors are retryable const code = (error as any).code; if ( code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ERR_NETWORK' ) { return true; } } return false; } /** * Gets retry delay in milliseconds based on error * @param error Error object * @param attempt Current retry attempt (0-indexed) * @returns Delay in milliseconds */ export function getRetryDelay(error: unknown, attempt: number = 0): number { if (error && typeof error === 'object' && 'code' in error) { const apiError = error as ApiError; const status = typeof apiError.code === 'number' ? apiError.code : 0; // Rate limit: use retry_after if available if (status === 429 && 'retry_after' in apiError) { const retryAfter = (apiError as any).retry_after; if (typeof retryAfter === 'number') { return retryAfter * 1000; } } } // Exponential backoff: 1s, 2s, 4s, 8s, max 30s return Math.min(1000 * Math.pow(2, attempt), 30000); }