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

301 lines
9.6 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: 'La requête est invalide. Veuillez vérifier les informations fournies.',
401: 'Vous devez être connecté pour effectuer cette action.',
403: "Vous n'avez pas les permissions nécessaires pour effectuer cette action.",
404: 'La ressource demandée est introuvable.',
409: 'Un conflit est survenu. Cette ressource existe déjà ou a été modifiée.',
422: 'Les données fournies ne sont pas valides.',
429: 'Trop de requêtes. Veuillez patienter quelques instants avant de réessayer.',
// Server errors (5xx)
500: "Une erreur serveur s'est produite. Veuillez réessayer plus tard.",
502: 'Erreur de communication avec le serveur. Veuillez réessayer plus tard.',
503: 'Service temporairement indisponible. Veuillez réessayer dans quelques instants.',
504: 'Le serveur met trop de temps à répondre. Veuillez réessayer plus tard.',
// Network errors
NETWORK:
'Erreur de connexion. Vérifiez votre connexion internet et réessayez. Si le problème persiste, le serveur pourrait être temporairement indisponible.',
TIMEOUT:
'La requête a expiré. Vérifiez votre connexion internet et réessayez.',
UNKNOWN: "Une erreur inattendue s'est produite. Veuillez réessayer.",
} as const;
/**
* Context-specific error messages
*/
export const CONTEXT_ERROR_MESSAGES = {
auth: {
login: 'Échec de la connexion. Vérifiez vos identifiants.',
logout: 'Erreur lors de la déconnexion.',
register: "Erreur lors de l'inscription. Veuillez réessayer.",
tokenExpired: 'Votre session a expiré. Veuillez vous reconnecter.',
},
upload: {
fileTooLarge: 'Le fichier est trop volumineux.',
invalidFormat: "Le format de fichier n'est pas supporté.",
uploadFailed: "L'upload a échoué. Veuillez réessayer.",
networkError: "Erreur réseau lors de l'upload. Vérifiez votre connexion.",
},
playlist: {
notFound: 'La playlist est introuvable.',
accessDenied: "Vous n'avez pas accès à cette playlist.",
createFailed: 'Erreur lors de la création de la playlist.',
updateFailed: 'Erreur lors de la mise à jour de la playlist.',
deleteFailed: 'Erreur lors de la suppression de la playlist.',
},
track: {
notFound: 'Le morceau est introuvable.',
playFailed: 'Impossible de lire le morceau. Vérifiez votre connexion.',
uploadFailed: "Erreur lors de l'upload du morceau.",
deleteFailed: 'Erreur lors de la suppression du morceau.',
},
conversation: {
notFound: 'La conversation est introuvable.',
accessDenied: "Vous n'avez pas accès à cette conversation.",
createFailed: 'Erreur lors de la création de la conversation.',
sendMessageFailed: "Erreur lors de l'envoi du message.",
},
search: {
failed: 'La recherche a échoué. Veuillez réessayer.',
timeout: 'La recherche a pris trop de temps. Veuillez réessayer.',
invalidQuery: 'La requête de recherche est invalide.',
},
} 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);
}