veza/apps/web/src/utils/errorMessages.ts

301 lines
9.1 KiB
TypeScript
Raw Normal View History

/**
* 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);
}