# 📗 Masterclass : Gestion des Erreurs en Ingénierie Logicielle ## Table des Matières 1. [Introduction à la Gestion des Erreurs](#1-introduction) 2. [Pourquoi la Gestion des Erreurs est Cruciale](#2-pourquoi-cest-important) 3. [Grands Principes de la Gestion des Erreurs](#3-grands-principes) 4. [Implémentation Frontend (React/Next.js)](#4-implementation-frontend) 5. [Implémentation Backend (NestJS)](#5-implementation-backend) 6. [Analyse du Repo ASF Frontend](#6-analyse-du-repo-asf-frontend) 7. [Problèmes Identifiés et Solutions](#7-problemes-identifies) 8. [Bonnes Pratiques et Recommandations](#8-bonnes-pratiques) 9. [Exemples Pratiques et Patterns](#9-exemples-pratiques) --- ## 1. Introduction à la Gestion des Erreurs ### Qu'est-ce que la Gestion des Erreurs ? La gestion des erreurs est le **processus de détection, traitement et récupération des conditions exceptionnelles** qui se produisent pendant l'exécution d'un programme. Elle englobe : - **Détection** : Identifier quand quelque chose ne va pas - **Classification** : Catégoriser le type d'erreur - **Notification** : Informer les parties concernées (utilisateurs, développeurs, systèmes) - **Récupération** : Tenter de revenir à un état stable - **Logging** : Enregistrer l'erreur pour analyse ultérieure ### Types d'Erreurs #### 1. Erreurs de Syntaxe ```typescript // Erreur détectée à la compilation const x = 5; const y = x.toUppercase(); // Erreur : toUppercase n'existe pas sur number ``` #### 2. Erreurs d'Exécution (Runtime) ```typescript // Erreur détectée pendant l'exécution const user = null; console.log(user.name); // TypeError: Cannot read property 'name' of null ``` #### 3. Erreurs Logiques ```typescript // Pas d'erreur technique, mais le résultat est incorrect function calculateDiscount(price: number) { return price * 1.1; // Bug logique : on augmente au lieu de réduire } ``` #### 4. Erreurs Réseau ```typescript // Problèmes de communication avec des services externes fetch('/api/users').catch(error => { // Connection refused, timeout, DNS error, etc. }); ``` #### 5. Erreurs de Validation ```typescript // Données qui ne respectent pas les contraintes const email = 'invalid-email'; // Ne respecte pas le format email const age = -5; // Valeur invalide ``` #### 6. Erreurs Métier ```typescript // Violations de règles métier class BankAccount { withdraw(amount: number) { if (this.balance < amount) { throw new InsufficientFundsError('Solde insuffisant'); } } } ``` --- ## 2. Pourquoi c'est Important ### 2.1 Expérience Utilisateur (UX) **Sans gestion d'erreurs :** ``` [Écran blanc] ``` **Avec gestion d'erreurs :** ``` ❌ Impossible de charger vos cours Problème de connexion réseau [Bouton: Réessayer] ``` **Impact :** - Réduction de la frustration utilisateur - Confiance dans l'application - Clarté sur ce qui se passe - Possibilité de récupération ### 2.2 Fiabilité du Système Une application sans gestion d'erreurs peut : - **Crasher complètement** pour une erreur mineure - **Perdre des données** utilisateur - **Rester dans un état corrompu** - **Propager des erreurs** en cascade **Exemple de propagation d'erreur :** ```typescript // Sans gestion d'erreurs async function processOrder(orderId: string) { const order = await getOrder(orderId); // ❌ Peut échouer const payment = await processPayment(order); // ❌ N'est jamais appelé si getOrder échoue await sendConfirmation(order); // ❌ N'est jamais appelé return order; } // Avec gestion d'erreurs async function processOrder(orderId: string) { try { const order = await getOrder(orderId); const payment = await processPayment(order); await sendConfirmation(order); return order; } catch (error) { // Log l'erreur ErrorLogger.log(error); // Rollback si nécessaire if (payment) { await refundPayment(payment); } // Notifier l'utilisateur throw new OrderProcessingError('Impossible de traiter la commande', { orderId }); } } ``` ### 2.3 Maintenabilité **Avantages pour les développeurs :** 1. **Debugging facilité** - Stack traces claires - Contexte de l'erreur - Logs structurés 2. **Monitoring et alertes** - Détection proactive des problèmes - Métriques d'erreurs - Tendances et patterns 3. **Documentation implicite** ```typescript // Le code documente les cas d'erreur possibles async function uploadFile(file: File) { if (file.size > MAX_SIZE) { throw new FileTooLargeError(`Max size: ${MAX_SIZE}`); } if (!ALLOWED_TYPES.includes(file.type)) { throw new InvalidFileTypeError(`Allowed: ${ALLOWED_TYPES.join(', ')}`); } // ... } ``` ### 2.4 Sécurité **Risques sans gestion d'erreurs appropriée :** 1. **Information Disclosure** ```typescript // ❌ MAL : Exposer des détails techniques catch (error) { res.status(500).json({ error: error.stack, // Révèle la structure interne query: sqlQuery, // Révèle les requêtes SQL config: dbConfig // Révèle les configurations }); } // ✅ BIEN : Message générique en production catch (error) { logger.error(error); // Log en interne uniquement res.status(500).json({ message: "Une erreur est survenue" }); } ``` 2. **SQL Injection via erreurs** ```typescript // Les messages d'erreur SQL peuvent révéler la structure de la DB // Il faut les masquer en production ``` ### 2.5 Coût Business **Impact des erreurs non gérées :** - **Perte de revenus** : Transactions échouées - **Perte de clients** : Mauvaise expérience - **Coût de support** : Tickets utilisateurs - **Réputation** : Avis négatifs, perte de confiance - **Temps de développement** : Debugging difficile sans logs **Statistiques :** - 88% des utilisateurs quittent une app après une mauvaise expérience - Le coût moyen d'un incident en production : $5,600 par minute (source: Gartner) - 70% du temps de développement est consacré au debugging --- ## 3. Grands Principes de la Gestion des Erreurs ### 3.1 Principe 1 : Fail Fast, Fail Loudly **Définition :** Détecter et signaler les erreurs le plus tôt possible. ```typescript // ❌ MAL : Retourner une valeur par défaut silencieusement function getUser(id: string) { const user = database.findUser(id); return user || {}; // ❌ Cache le problème } // ✅ BIEN : Échouer explicitement function getUser(id: string) { const user = database.findUser(id); if (!user) { throw new UserNotFoundError(`User ${id} not found`); } return user; } ``` **Avantages :** - Bugs détectés immédiatement - Pas de propagation de données corrompues - Stack trace au point exact de l'erreur ### 3.2 Principe 2 : Separation of Concerns **Définition :** Séparer la détection, le traitement et la présentation des erreurs. **Architecture en couches :** ``` ┌─────────────────────────────────────┐ │ Présentation (UI) │ ← Affiche à l'utilisateur ├─────────────────────────────────────┤ │ Application (Business Logic) │ ← Traite et transforme ├─────────────────────────────────────┤ │ Infrastructure (API, DB) │ ← Détecte et lance └─────────────────────────────────────┘ ``` **Exemple :** ```typescript // Infrastructure Layer : Détection class UserRepository { async findById(id: string) { try { const result = await db.query('SELECT * FROM users WHERE id = ?', [id]); if (!result) { throw new EntityNotFoundError('User', id); } return result; } catch (error) { if (error instanceof DatabaseError) { throw new RepositoryError('Failed to fetch user', { cause: error }); } throw error; } } } // Application Layer : Traitement class UserService { async getUserProfile(userId: string) { try { const user = await this.userRepository.findById(userId); return this.mapToProfileDTO(user); } catch (error) { if (error instanceof EntityNotFoundError) { // Log métrique metrics.increment('user.not_found'); // Re-throw pour la couche supérieure throw new UserNotFoundError(`Profile not found for user ${userId}`); } // Log l'erreur inattendue logger.error('Failed to get user profile', { userId, error }); throw new ServiceError('Unable to retrieve user profile'); } } } // Presentation Layer : Affichage class UserController { async getProfile(req, res) { try { const profile = await this.userService.getUserProfile(req.params.id); res.json(profile); } catch (error) { if (error instanceof UserNotFoundError) { res.status(404).json({ messageFR: 'Utilisateur non trouvé', messageEN: 'User not found', }); } else if (error instanceof ServiceError) { res.status(500).json({ messageFR: 'Erreur lors de la récupération du profil', messageEN: 'Failed to retrieve profile', }); } else { res.status(500).json({ messageFR: 'Une erreur inattendue est survenue', messageEN: 'An unexpected error occurred', }); } } } } ``` ### 3.3 Principe 3 : Error Classification **Définition :** Catégoriser les erreurs pour un traitement approprié. **Catégories communes :** ```typescript enum ErrorCategory { // Erreurs utilisateur (4xx) VALIDATION = 'VALIDATION', // 400, 422 - Données invalides AUTH = 'AUTH', // 401, 403 - Authentification/autorisation NOT_FOUND = 'NOT_FOUND', // 404 - Ressource inexistante CONFLICT = 'CONFLICT', // 409 - Conflit de ressource // Erreurs système (5xx) SERVER = 'SERVER', // 500 - Erreur serveur interne NETWORK = 'NETWORK', // - Problème de connexion TIMEOUT = 'TIMEOUT', // 408, 504 - Timeout UNAVAILABLE = 'UNAVAILABLE', // 503 - Service indisponible // Erreurs application RUNTIME = 'RUNTIME', // - Erreur d'exécution BUSINESS = 'BUSINESS', // - Règle métier violée UNKNOWN = 'UNKNOWN', // - Erreur non classifiée } enum ErrorSeverity { LOW = 'LOW', // Récupérable, impact minimal MEDIUM = 'MEDIUM', // Impact modéré, nécessite attention HIGH = 'HIGH', // Impact important, nécessite action FATAL = 'FATAL', // Application inutilisable } ``` **Exemple d'utilisation :** ```typescript function classifyError(error: unknown): AppError { // Erreur HTTP if (error instanceof HttpError) { const status = error.status; if (status === 401 || status === 403) { return { category: ErrorCategory.AUTH, severity: ErrorSeverity.HIGH, message: 'Session expirée', recoverable: true, retryable: false, }; } if (status >= 500) { return { category: ErrorCategory.SERVER, severity: ErrorSeverity.FATAL, message: 'Erreur serveur', recoverable: false, retryable: true, }; } } // Erreur réseau if (error instanceof NetworkError) { return { category: ErrorCategory.NETWORK, severity: ErrorSeverity.MEDIUM, message: 'Problème de connexion', recoverable: true, retryable: true, }; } // Erreur par défaut return { category: ErrorCategory.UNKNOWN, severity: ErrorSeverity.MEDIUM, message: 'Une erreur est survenue', }; } ``` ### 3.4 Principe 4 : Graceful Degradation **Définition :** L'application continue de fonctionner avec des capacités réduites en cas d'erreur. **Exemples :** ```typescript // 1. Fallback sur cache async function getProducts() { try { return await api.getProducts(); } catch (error) { logger.warn('API unavailable, using cache', error); return cache.getProducts(); // Données potentiellement anciennes } } // 2. Fonctionnalité optionnelle async function loadDashboard() { const [userData, recommendationsResult, statisticsResult] = await Promise.allSettled([ api.getUserData(), // Critique api.getRecommendations(), // Optionnel api.getStatistics() // Optionnel ]); if (userData.status === 'rejected') { throw new Error('Cannot load dashboard without user data'); } return { user: userData.value, recommendations: recommendationsResult.status === 'fulfilled' ? recommendationsResult.value : [], statistics: statisticsResult.status === 'fulfilled' ? statisticsResult.value : null }; } // 3. Mode dégradé d'UI function UserAvatar({ userId }) { const { data: avatar, error } = useAvatar(userId); if (error) { return ; // Fallback visuel } return ; } ``` ### 3.5 Principe 5 : Meaningful Error Messages **Définition :** Messages clairs, actionnables et adaptés à l'audience. **Pour les utilisateurs :** ```typescript // ❌ MAL 'Error: ECONNREFUSED'; 'Exception in thread main: NullPointerException'; 'Error 500'; // ✅ BIEN 'Impossible de se connecter au serveur. Vérifiez votre connexion internet.'; "L'adresse email est déjà utilisée. Veuillez en choisir une autre."; 'Le service est temporairement indisponible. Réessayez dans quelques minutes.'; ``` **Pour les développeurs :** ```typescript class AppError extends Error { constructor( message: string, public readonly code: string, public readonly context?: Record ) { super(message); this.name = this.constructor.name; } toJSON() { return { name: this.name, message: this.message, code: this.code, context: this.context, stack: this.stack, }; } } // Utilisation throw new UserNotFoundError('User not found', 'USER_NOT_FOUND', { userId, requestId, timestamp }); ``` ### 3.6 Principe 6 : Error Logging & Monitoring **Définition :** Enregistrer et surveiller les erreurs pour analyse et amélioration. **Niveaux de logging :** ```typescript enum LogLevel { ERROR = 'error', // Erreurs nécessitant une action immédiate WARN = 'warn', // Situations anormales mais gérables INFO = 'info', // Événements importants DEBUG = 'debug', // Détails pour debugging } // Exemple de logger structuré class Logger { error(message: string, context?: Record) { const log = { level: 'ERROR', timestamp: new Date().toISOString(), message, context, environment: process.env.NODE_ENV, version: process.env.APP_VERSION, }; // Console en développement if (process.env.NODE_ENV === 'development') { console.error(log); } // Service de monitoring en production if (process.env.NODE_ENV === 'production') { Sentry.captureException(new Error(message), { extra: context, }); } // Stockage local pour analyse this.persistToStorage(log); } } ``` **Métriques à surveiller :** 1. **Taux d'erreur** : Nombre d'erreurs / Total de requêtes 2. **Catégories d'erreurs** : Distribution par type 3. **Pages/endpoints affectés** : Où se produisent les erreurs 4. **Tendances** : Augmentation ou diminution 5. **Impact utilisateur** : Nombre d'utilisateurs affectés ### 3.7 Principe 7 : Retry Logic **Définition :** Réessayer automatiquement les opérations qui peuvent réussir après une tentative échouée. **Quand réessayer :** - ✅ Erreurs réseau transitoires - ✅ Timeouts - ✅ Erreurs serveur 5xx (sauf 501) - ✅ Service temporairement indisponible (503) - ❌ Erreurs de validation (4xx) - ❌ Erreurs d'authentification (401, 403) - ❌ Ressource non trouvée (404) **Stratégies de retry :** ```typescript // 1. Retry simple avec nombre de tentatives fixe async function retrySimple(fn: () => Promise, maxRetries: number = 3): Promise { let lastError: Error; for (let i = 0; i <= maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; if (i < maxRetries) { console.log(`Retry ${i + 1}/${maxRetries}`); } } } throw lastError; } // 2. Exponential Backoff (recommandé) async function retryWithBackoff( fn: () => Promise, maxRetries: number = 3, baseDelay: number = 1000 ): Promise { let lastError: Error; for (let i = 0; i <= maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; if (i < maxRetries && isRetryable(error)) { const delay = baseDelay * Math.pow(2, i); // 1s, 2s, 4s, 8s... const jitter = Math.random() * 1000; // Ajoute du bruit await sleep(delay + jitter); console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`); } } } throw lastError; } // 3. Retry avec conditions async function retryWithCondition( fn: () => Promise, shouldRetry: (error: Error) => boolean, maxRetries: number = 3 ): Promise { let lastError: Error; for (let i = 0; i <= maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; if (i < maxRetries && shouldRetry(error)) { await sleep(1000 * Math.pow(2, i)); } else { break; } } } throw lastError; } // Utilisation const data = await retryWithBackoff(() => api.fetchData(), 3, 1000); ``` ### 3.8 Principe 8 : Error Boundaries **Définition :** Isoler les erreurs pour éviter qu'elles ne crashent toute l'application. **Frontend (React) :** ```typescript class ErrorBoundary extends React.Component { state = { hasError: false, error: null }; static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { // Log l'erreur ErrorLogger.log(error, { componentStack: errorInfo.componentStack }); } render() { if (this.state.hasError) { return ; } return this.props.children; } } // Utilisation ``` **Backend (NestJS) :** ```typescript // Exception Filter global @Catch() export class GlobalExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const error = this.normalizeError(exception); // Log l'erreur Logger.error(error.message, { path: request.url, method: request.method, stack: error.stack, }); // Retourner réponse appropriée response.status(error.status).json({ statusCode: error.status, messageFR: error.messageFR, messageEN: error.messageEN, timestamp: new Date().toISOString(), path: request.url, }); } } ``` --- ## 4. Implémentation Frontend (React/Next.js) ### 4.1 Architecture de Gestion des Erreurs ``` ┌─────────────────────────────────────────────────┐ │ UI Layer (Components) │ │ - Affichage des erreurs │ │ - Error Boundaries │ │ - Toast notifications │ └────────────────┬────────────────────────────────┘ │ ┌────────────────▼────────────────────────────────┐ │ Hook Layer (useQuery, useMutation) │ │ - Gestion des états de chargement/erreur │ │ - Retry logic │ │ - Cache invalidation │ └────────────────┬────────────────────────────────┘ │ ┌────────────────▼────────────────────────────────┐ │ Service Layer │ │ - Transformation des erreurs │ │ - Enrichissement du contexte │ │ - Mapping DTO │ └────────────────┬────────────────────────────────┘ │ ┌────────────────▼────────────────────────────────┐ │ HTTP Client Layer │ │ - Intercepteurs │ │ - Gestion des tokens │ │ - Retry automatique │ └────────────────┬────────────────────────────────┘ │ ┌────────────────▼────────────────────────────────┐ │ Error Utils (Classification, Logging) │ │ - Classification des erreurs │ │ - Logging structuré │ │ - Monitoring (Sentry, etc.) │ └─────────────────────────────────────────────────┘ ``` ### 4.2 HTTP Client avec Gestion d'Erreurs ```typescript // httpClient.ts import axios, { AxiosInstance, AxiosError } from 'axios'; export class HttpError extends Error { constructor( message: string, public readonly status: number, public readonly data?: unknown ) { super(message); this.name = 'HttpError'; } } export class HttpClient { private client: AxiosInstance; private isRefreshing = false; private failedQueue: Array<{ resolve: (value: unknown) => void; reject: (error: unknown) => void; }> = []; constructor(baseURL: string) { this.client = axios.create({ baseURL, timeout: 30000, // 30 secondes headers: { 'Content-Type': 'application/json', }, }); this.setupInterceptors(); } private setupInterceptors() { // Request interceptor this.client.interceptors.request.use( config => { const token = this.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, error => Promise.reject(error) ); // Response interceptor this.client.interceptors.response.use( response => response, async (error: AxiosError) => { const originalRequest = error.config; // Gestion du refresh token if (error.response?.status === 401 && !originalRequest._retry) { if (this.isRefreshing) { return new Promise((resolve, reject) => { this.failedQueue.push({ resolve, reject }); }); } originalRequest._retry = true; this.isRefreshing = true; try { const newToken = await this.refreshAccessToken(); this.processQueue(null, newToken); originalRequest.headers.Authorization = `Bearer ${newToken}`; return this.client(originalRequest); } catch (refreshError) { this.processQueue(refreshError, null); this.clearTokens(); return Promise.reject(refreshError); } finally { this.isRefreshing = false; } } // Transformer en HttpError return Promise.reject(this.transformError(error)); } ); } private transformError(error: AxiosError): HttpError { if (error.response) { // Le serveur a répondu avec un statut d'erreur return new HttpError(error.message, error.response.status, error.response.data); } else if (error.request) { // La requête a été envoyée mais pas de réponse return new HttpError('Problème de connexion réseau', 0, { originalError: error }); } else { // Erreur lors de la configuration de la requête return new HttpError(error.message, 0, { originalError: error }); } } async get(url: string): Promise { const response = await this.client.get(url); return response.data; } async post(url: string, data?: unknown): Promise { const response = await this.client.post(url, data); return response.data; } // ... autres méthodes } ``` ### 4.3 Service Layer avec Error Handling ```typescript // coursesService.ts export class CoursesService extends HttpClient { async getAllCourses(): Promise { try { const response = await this.get('/cours'); if (!response || !Array.isArray(response)) { throw new Error('Invalid response format'); } return response.map(course => CoursesMapper.mapApiCourseToCourseEntity(course)); } catch (error) { // Classifier l'erreur const classifiedError = classifyError(error); // Logger avec contexte ErrorLogger.log(classifiedError, { service: 'CoursesService', method: 'getAllCourses', timestamp: new Date().toISOString(), }); // Re-throw pour que les hooks puissent gérer throw classifiedError; } } async getCourseById(id: string): Promise { try { const response = await this.get(`/cours/${id}`); if (!response) { throw new NotFoundError(`Course with id ${id} not found`); } return CoursesMapper.mapApiCourseToCourseEntity(response); } catch (error) { if (error instanceof HttpError && error.status === 404) { throw new NotFoundError(`Course ${id} not found`); } const classifiedError = classifyError(error); ErrorLogger.log(classifiedError, { service: 'CoursesService', method: 'getCourseById', courseId: id, }); throw classifiedError; } } } ``` ### 4.4 React Query Hooks avec Error Handling ```typescript // useCourses.ts export function useCourses() { const queryClient = useQueryClient(); return useQuery({ queryKey: ['courses'], queryFn: () => coursesService.getAllCourses(), retry: (failureCount, error) => { // Ne pas retry les erreurs client (4xx) if (error instanceof HttpError && error.status >= 400 && error.status < 500) { return false; } // Retry jusqu'à 3 fois pour les erreurs serveur return failureCount < 3; }, retryDelay: attemptIndex => { // Exponential backoff: 1s, 2s, 4s return Math.min(1000 * 2 ** attemptIndex, 30000); }, staleTime: 5 * 60 * 1000, // 5 minutes onError: error => { const classified = classifyError(error); // Log l'erreur ErrorLogger.log(classified, { hook: 'useCourses', action: 'fetch', }); // Notifier l'utilisateur selon la sévérité if ( classified.severity === ErrorSeverity.HIGH || classified.severity === ErrorSeverity.FATAL ) { toastService.error( { title: 'Erreur de chargement', fallbackDescription: 'Impossible de charger les cours', }, error ); } }, }); } // useEnrollCourse.ts (Mutation) export function useEnrollCourse() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (courseId: string) => coursesService.enrollCourse(courseId), onSuccess: (data, courseId) => { // Invalider les caches queryClient.invalidateQueries({ queryKey: ['courses'] }); queryClient.invalidateQueries({ queryKey: ['enrolled-courses'] }); // Notifier succès toastService.success({ title: 'Inscription réussie', description: 'Vous êtes maintenant inscrit au cours', }); }, onError: (error, courseId) => { const classified = classifyError(error); // Logger ErrorLogger.log(classified, { hook: 'useEnrollCourse', action: 'enroll', courseId, }); // Notifier l'utilisateur avec message approprié if (classified.category === ErrorCategory.AUTH) { toastService.error( { title: 'Session expirée', fallbackDescription: 'Veuillez vous reconnecter', }, error ); } else if (classified.category === ErrorCategory.CONFLICT) { toastService.error( { title: 'Déjà inscrit', fallbackDescription: 'Vous êtes déjà inscrit à ce cours', }, error ); } else { toastService.error( { title: "Erreur d'inscription", fallbackDescription: 'Impossible de vous inscrire au cours', }, error ); } }, // Pas de retry pour les mutations par défaut retry: false, }); } ``` ### 4.5 Components avec Error Display ```typescript // CourseList.tsx export function CourseList() { const { data: courses, isLoading, error, refetch } = useCourses(); // Afficher le loading if (isLoading) { return ; } // Afficher l'erreur si présente if (error) { const classified = classifyError(error); return ( ); } // Afficher le contenu if (!courses || courses.length === 0) { return ; } return (
{courses.map(course => ( ))}
); } // ErrorDisplay.tsx interface ErrorDisplayProps { title: string; message: string; severity: ErrorSeverity; onRetry?: () => void; } export function ErrorDisplay({ title, message, severity, onRetry }: ErrorDisplayProps) { const Icon = getIconBySeverity(severity); const color = getColorBySeverity(severity); return (

{title}

{message}

{onRetry && ( )}
); } function getColorBySeverity(severity: ErrorSeverity) { switch (severity) { case ErrorSeverity.FATAL: return { border: 'border-red-500', bg: 'bg-red-50', text: 'text-red-800' }; case ErrorSeverity.HIGH: return { border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-800' }; case ErrorSeverity.MEDIUM: return { border: 'border-yellow-500', bg: 'bg-yellow-50', text: 'text-yellow-800' }; case ErrorSeverity.LOW: return { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-800' }; } } ``` ### 4.6 Form Error Handling avec React Hook Form ```typescript // LoginForm.tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const loginSchema = z.object({ email: z.string().email('Email invalide'), password: z.string().min(6, 'Minimum 6 caractères') }); type LoginFormData = z.infer; export function LoginForm() { const { register, handleSubmit, formState: { errors }, setError } = useForm({ resolver: zodResolver(loginSchema) }); const { mutate: login, isLoading } = useLogin(); const onSubmit = async (data: LoginFormData) => { try { await login(data); } catch (error) { // Gérer les erreurs de validation backend if (error instanceof HttpError && error.status === 422) { const validationErrors = extractValidationErrors(error); // Mapper les erreurs aux champs du formulaire if (validationErrors) { Object.entries(validationErrors).forEach(([field, messages]) => { setError(field as keyof LoginFormData, { type: 'server', message: messages[0] }); }); } } } }; return (
{errors.email && ( {errors.email.message} )}
{errors.password && ( {errors.password.message} )}
); } // FormErrorMessage.tsx export function FormErrorMessage({ children }: { children: React.ReactNode }) { return (

{children}

); } ``` ### 4.7 Error Boundaries ```typescript // ErrorBoundary.tsx import React, { Component, ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; fallback?: (error: Error) => ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void; } interface State { hasError: boolean; error: Error | null; } export class ErrorBoundary extends Component { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { // Classifier l'erreur const classified = classifyError(error); // Logger ErrorLogger.log(classified, { componentStack: errorInfo.componentStack, boundary: 'ErrorBoundary' }); // Callback custom this.props.onError?.(error, errorInfo); } render() { if (this.state.hasError && this.state.error) { if (this.props.fallback) { return this.props.fallback(this.state.error); } return ; } return this.props.children; } } // DefaultErrorFallback.tsx function DefaultErrorFallback({ error }: { error: Error }) { const classified = classifyError(error); return (

Une erreur est survenue

{classified.message}

{process.env.NODE_ENV === 'development' && (
Détails techniques
              {error.stack}
            
)}
); } // Utilisation dans l'app // app/layout.tsx export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ); } // app/dashboard/layout.tsx export default function DashboardLayout({ children }: { children: ReactNode }) { const router = useRouter(); return ( { const classified = classifyError(error); // Rediriger vers login si erreur d'auth if (classified.category === ErrorCategory.AUTH) { router.push('/auth/login'); } }} > {children} ); } ``` --- ## 5. Implémentation Backend (NestJS) ### 5.1 Architecture de Gestion des Erreurs Backend ``` ┌──────────────────────────────────────────────────┐ │ Controller Layer │ │ - Validation des inputs (DTOs) │ │ - Exception filters │ └────────────────┬─────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────┐ │ Service Layer (Business Logic) │ │ - Logique métier │ │ - Lancement d'exceptions custom │ └────────────────┬─────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────┐ │ Repository Layer │ │ - Accès base de données │ │ - Gestion des erreurs DB │ └────────────────┬─────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────┐ │ Global Exception Filter │ │ - Catch toutes les exceptions │ │ - Formatage des réponses │ │ - Logging │ └──────────────────────────────────────────────────┘ ``` ### 5.2 Custom Exceptions ```typescript // exceptions/base.exception.ts export abstract class BaseException extends Error { constructor( public readonly message: string, public readonly messageFR: string, public readonly messageEN: string, public readonly statusCode: number, public readonly errorCode: string, public readonly context?: Record ) { super(message); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } toJSON() { return { name: this.name, message: this.message, messageFR: this.messageFR, messageEN: this.messageEN, statusCode: this.statusCode, errorCode: this.errorCode, context: this.context, timestamp: new Date().toISOString(), }; } } // exceptions/business.exception.ts export class BusinessException extends BaseException { constructor( messageFR: string, messageEN: string, errorCode: string, context?: Record ) { super( messageFR, // Message par défaut en FR messageFR, messageEN, 400, // Bad Request errorCode, context ); } } // exceptions/not-found.exception.ts export class NotFoundException extends BaseException { constructor(resource: string, id: string | number, context?: Record) { const messageFR = `${resource} avec l'identifiant ${id} introuvable`; const messageEN = `${resource} with id ${id} not found`; super(messageFR, messageFR, messageEN, 404, 'RESOURCE_NOT_FOUND', { resource, id, ...context }); } } // exceptions/validation.exception.ts export class ValidationException extends BaseException { constructor( public readonly errors: Record, context?: Record ) { super( 'Erreur de validation', 'Les données fournies sont invalides', 'Validation failed', 422, // Unprocessable Entity 'VALIDATION_ERROR', { errors, ...context } ); } } // exceptions/auth.exception.ts export class UnauthorizedException extends BaseException { constructor(reason?: string, context?: Record) { super( reason || 'Non authentifié', 'Vous devez être connecté pour accéder à cette ressource', 'You must be authenticated to access this resource', 401, 'UNAUTHORIZED', context ); } } export class ForbiddenException extends BaseException { constructor(reason?: string, context?: Record) { super( reason || 'Accès interdit', "Vous n'avez pas les permissions nécessaires", "You don't have permission to access this resource", 403, 'FORBIDDEN', context ); } } // exceptions/conflict.exception.ts export class ConflictException extends BaseException { constructor(messageFR: string, messageEN: string, context?: Record) { super(messageFR, messageFR, messageEN, 409, 'CONFLICT', context); } } ``` ### 5.3 Global Exception Filter ```typescript // filters/global-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; import { QueryFailedError } from 'typeorm'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(GlobalExceptionFilter.name); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const errorResponse = this.buildErrorResponse(exception, request); // Logger l'erreur this.logError(exception, request, errorResponse); // Envoyer la réponse response.status(errorResponse.statusCode).json(errorResponse); } private buildErrorResponse(exception: unknown, request: Request) { const timestamp = new Date().toISOString(); const path = request.url; const method = request.method; // Exception custom if (exception instanceof BaseException) { return { statusCode: exception.statusCode, messageFR: exception.messageFR, messageEN: exception.messageEN, errorCode: exception.errorCode, context: exception.context, timestamp, path, method, }; } // HttpException de NestJS if (exception instanceof HttpException) { const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); return { statusCode: status, messageFR: this.extractMessage(exceptionResponse), messageEN: this.extractMessage(exceptionResponse), errorCode: this.getErrorCodeFromStatus(status), timestamp, path, method, }; } // Erreur TypeORM if (exception instanceof QueryFailedError) { return this.handleDatabaseError(exception, timestamp, path, method); } // Erreur inconnue return { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, messageFR: 'Une erreur interne est survenue', messageEN: 'An internal server error occurred', errorCode: 'INTERNAL_ERROR', timestamp, path, method, // Seulement en développement ...(process.env.NODE_ENV === 'development' && { details: exception instanceof Error ? exception.message : String(exception), stack: exception instanceof Error ? exception.stack : undefined, }), }; } private handleDatabaseError( error: QueryFailedError, timestamp: string, path: string, method: string ) { const driverError = error.driverError; // Contrainte unique violée if (driverError.code === '23505') { return { statusCode: HttpStatus.CONFLICT, messageFR: 'Cette ressource existe déjà', messageEN: 'This resource already exists', errorCode: 'DUPLICATE_ENTRY', timestamp, path, method, }; } // Contrainte de clé étrangère violée if (driverError.code === '23503') { return { statusCode: HttpStatus.BAD_REQUEST, messageFR: 'Référence invalide', messageEN: 'Invalid reference', errorCode: 'FOREIGN_KEY_VIOLATION', timestamp, path, method, }; } // Erreur DB générique return { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, messageFR: 'Erreur de base de données', messageEN: 'Database error', errorCode: 'DATABASE_ERROR', timestamp, path, method, }; } private logError(exception: unknown, request: Request, errorResponse: any) { const { statusCode, errorCode, messageFR } = errorResponse; const { method, url, ip } = request; const context = { statusCode, errorCode, method, url, ip, userAgent: request.get('user-agent'), userId: request['user']?.id, }; // Log selon la sévérité if (statusCode >= 500) { this.logger.error( `${method} ${url} - ${errorCode}: ${messageFR}`, exception instanceof Error ? exception.stack : undefined, JSON.stringify(context) ); } else if (statusCode >= 400) { this.logger.warn(`${method} ${url} - ${errorCode}: ${messageFR}`, JSON.stringify(context)); } else { this.logger.log(`${method} ${url} - ${errorCode}: ${messageFR}`, JSON.stringify(context)); } // Envoyer à Sentry en production if (process.env.NODE_ENV === 'production' && statusCode >= 500) { // Sentry.captureException(exception, { extra: context }); } } private extractMessage(response: string | object): string { if (typeof response === 'string') { return response; } if (typeof response === 'object' && 'message' in response) { return Array.isArray(response.message) ? response.message[0] : response.message; } return 'Une erreur est survenue'; } private getErrorCodeFromStatus(status: number): string { const codes = { 400: 'BAD_REQUEST', 401: 'UNAUTHORIZED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 422: 'VALIDATION_ERROR', 500: 'INTERNAL_ERROR', 503: 'SERVICE_UNAVAILABLE', }; return codes[status] || 'UNKNOWN_ERROR'; } } ``` ### 5.4 Validation avec DTOs ```typescript // dtos/create-course.dto.ts import { IsString, IsNotEmpty, IsEnum, IsNumber, Min, Max, IsOptional, IsUrl, } from 'class-validator'; export enum CourseLevel { BEGINNER = 'BEGINNER', INTERMEDIATE = 'INTERMEDIATE', ADVANCED = 'ADVANCED', } export class CreateCourseDto { @IsString({ message: 'Le titre doit être une chaîne de caractères' }) @IsNotEmpty({ message: 'Le titre est obligatoire' }) title: string; @IsString({ message: 'La description doit être une chaîne de caractères' }) @IsNotEmpty({ message: 'La description est obligatoire' }) description: string; @IsEnum(CourseLevel, { message: 'Niveau invalide' }) level: CourseLevel; @IsNumber({}, { message: 'La durée doit être un nombre' }) @Min(1, { message: 'La durée minimale est de 1 heure' }) @Max(1000, { message: 'La durée maximale est de 1000 heures' }) duration: number; @IsOptional() @IsUrl({}, { message: "L'URL de l'image est invalide" }) imageUrl?: string; } // pipes/validation.pipe.ts import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; import { validate } from 'class-validator'; import { plainToClass } from 'class-transformer'; @Injectable() export class ValidationPipe implements PipeTransform { async transform(value: any, { metatype }: ArgumentMetadata) { if (!metatype || !this.toValidate(metatype)) { return value; } const object = plainToClass(metatype, value); const errors = await validate(object); if (errors.length > 0) { const formattedErrors = this.formatErrors(errors); throw new ValidationException(formattedErrors); } return value; } private toValidate(metatype: Function): boolean { const types: Function[] = [String, Boolean, Number, Array, Object]; return !types.includes(metatype); } private formatErrors(errors: any[]): Record { const formatted: Record = {}; errors.forEach(error => { formatted[error.property] = Object.values(error.constraints); }); return formatted; } } ``` ### 5.5 Service Layer avec Error Handling ```typescript // courses/courses.service.ts import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @Injectable() export class CoursesService { private readonly logger = new Logger(CoursesService.name); constructor( @InjectRepository(Course) private coursesRepository: Repository ) {} async findAll(): Promise { try { return await this.coursesRepository.find(); } catch (error) { this.logger.error('Failed to fetch courses', error.stack); throw new BusinessException( 'Erreur lors de la récupération des cours', 'Failed to fetch courses', 'COURSES_FETCH_ERROR' ); } } async findOne(id: string): Promise { const course = await this.coursesRepository.findOne({ where: { id } }); if (!course) { throw new NotFoundException('Course', id); } return course; } async create(createCourseDto: CreateCourseDto): Promise { try { // Vérifier si le cours existe déjà const existing = await this.coursesRepository.findOne({ where: { title: createCourseDto.title }, }); if (existing) { throw new ConflictException( 'Un cours avec ce titre existe déjà', 'A course with this title already exists', { title: createCourseDto.title } ); } const course = this.coursesRepository.create(createCourseDto); return await this.coursesRepository.save(course); } catch (error) { if (error instanceof BaseException) { throw error; } this.logger.error('Failed to create course', error.stack); throw new BusinessException( 'Erreur lors de la création du cours', 'Failed to create course', 'COURSE_CREATE_ERROR', { dto: createCourseDto } ); } } async enroll(userId: string, courseId: string): Promise { // Vérifier que le cours existe const course = await this.findOne(courseId); // Vérifier que l'utilisateur n'est pas déjà inscrit const existingEnrollment = await this.enrollmentsRepository.findOne({ where: { userId, courseId }, }); if (existingEnrollment) { throw new ConflictException( 'Vous êtes déjà inscrit à ce cours', 'You are already enrolled in this course', { userId, courseId } ); } // Vérifier que le cours n'est pas complet const enrollmentCount = await this.enrollmentsRepository.count({ where: { courseId }, }); if (enrollmentCount >= course.maxStudents) { throw new BusinessException('Ce cours est complet', 'This course is full', 'COURSE_FULL', { courseId, maxStudents: course.maxStudents, }); } // Créer l'inscription try { const enrollment = this.enrollmentsRepository.create({ userId, courseId, enrolledAt: new Date(), }); await this.enrollmentsRepository.save(enrollment); } catch (error) { this.logger.error('Failed to enroll user', error.stack); throw new BusinessException( "Erreur lors de l'inscription", 'Failed to enroll', 'ENROLLMENT_ERROR', { userId, courseId } ); } } } ``` ### 5.6 Controller avec Error Handling ```typescript // courses/courses.controller.ts import { Controller, Get, Post, Body, Param, UseGuards, HttpCode, HttpStatus, } from '@nestjs/common'; @Controller('cours') export class CoursesController { constructor(private readonly coursesService: CoursesService) {} @Get() async findAll() { try { return await this.coursesService.findAll(); } catch (error) { // L'erreur sera catchée par le GlobalExceptionFilter throw error; } } @Get(':id') async findOne(@Param('id') id: string) { return await this.coursesService.findOne(id); } @Post() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ADMIN) @HttpCode(HttpStatus.CREATED) async create(@Body(ValidationPipe) createCourseDto: CreateCourseDto) { return await this.coursesService.create(createCourseDto); } @Post(':id/enroll') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) async enroll(@Param('id') courseId: string, @CurrentUser() user: User) { await this.coursesService.enroll(user.id, courseId); return { messageFR: 'Inscription réussie', messageEN: 'Enrollment successful', courseId, }; } } ``` ### 5.7 Interceptors pour Logging ```typescript // interceptors/logging.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(LoggingInterceptor.name); intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const { method, url, body, ip } = request; const userAgent = request.get('user-agent') || ''; const startTime = Date.now(); this.logger.log(`Incoming Request: ${method} ${url} - IP: ${ip} - UserAgent: ${userAgent}`); return next.handle().pipe( tap(data => { const responseTime = Date.now() - startTime; this.logger.log(`Response: ${method} ${url} - ${responseTime}ms`); }), catchError(error => { const responseTime = Date.now() - startTime; this.logger.error(`Error Response: ${method} ${url} - ${responseTime}ms`, error.stack); throw error; }) ); } } ``` ### 5.8 Main Setup ```typescript // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { GlobalExceptionFilter } from './filters/global-exception.filter'; import { ValidationPipe } from './pipes/validation.pipe'; import { LoggingInterceptor } from './interceptors/logging.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); // Global exception filter app.useGlobalFilters(new GlobalExceptionFilter()); // Global validation pipe app.useGlobalPipes(new ValidationPipe()); // Global logging interceptor app.useGlobalInterceptors(new LoggingInterceptor()); // CORS app.enableCors({ origin: process.env.FRONTEND_URL, credentials: true, }); await app.listen(3000); } bootstrap(); ``` --- ## 6. Analyse du Repo ASF Frontend ### 6.1 Structure Actuelle Le repo ASF Frontend possède une architecture de gestion d'erreurs bien pensée avec les composants suivants : ``` src/ ├── core/ │ └── http/ │ └── httpClient.ts # Client HTTP avec interceptors ├── lib/ │ ├── api/ │ │ └── responseMessages.ts # Extraction messages d'erreur │ ├── errors/ │ │ ├── errorTypes.ts # Types et enums │ │ ├── errorClassifier.ts # Classification des erreurs │ │ └── errorLogger.ts # Logging centralisé │ └── toast/ │ └── toastService.ts # Notifications utilisateur ├── services/ │ ├── courses/ │ │ └── coursesService.ts # Service des cours │ └── modules/ │ └── modulesService.ts # Service des modules ├── components/ │ └── errors/ │ └── ErrorBoundary.tsx # Error boundary React └── app/ ├── global-error.tsx # Global error handler └── [locale]/ ├── error.tsx # Error handler locale └── dashboard/ └── error.tsx # Error handler dashboard ``` ### 6.2 Points Forts Identifiés #### ✅ 1. Système de Classification Robuste ```typescript // src/lib/errors/errorTypes.ts enum ErrorCategory { NETWORK, AUTH, VALIDATION, NOT_FOUND, SERVER, UNKNOWN, RUNTIME, } enum ErrorSeverity { LOW, MEDIUM, HIGH, FATAL, } interface AppError { message: string; status?: number; category: ErrorCategory; severity: ErrorSeverity; details?: unknown; stack?: string; originalError?: Error; } ``` **Avantage :** Permet un traitement différencié selon le type et la gravité. #### ✅ 2. Error Logger avec Rate Limiting ```typescript // src/lib/errors/errorLogger.ts export class ErrorLogger { private static errorCount = 0; private static lastResetTime = Date.now(); private static readonly MAX_ERRORS_PER_MINUTE = 10; static log(error: AppError, context: ErrorContext = {}): void { // Rate limiting if (this.errorCount >= this.MAX_ERRORS_PER_MINUTE) { return; } // Log to console // Log to localStorage // TODO: Log to monitoring service (Sentry) } } ``` **Avantage :** Prévient le spam de logs tout en capturant les erreurs importantes. #### ✅ 3. Toast Service Intelligent ```typescript // src/lib/toast/toastService.ts export const toastService = { error(options: ToastOptions, error?: unknown) { const backendMessage = error ? extractErrorMessage(error, options.fallbackDescription) : options.fallbackDescription; toast.error(options.title, { description: backendMessage }); }, }; ``` **Avantage :** Extrait automatiquement les messages localisés du backend. #### ✅ 4. HTTP Client avec Token Refresh ```typescript // src/core/http/httpClient.ts // Token refresh avec queue des requêtes échouées private isRefreshing = false; private failedQueue: Array<{ resolve: (value: unknown) => void; reject: (error: unknown) => void; }> = []; ``` **Avantage :** Gère élégamment l'expiration des tokens sans répéter les requêtes. #### ✅ 5. Error Boundaries Multi-Niveaux - `global-error.tsx` : Erreurs catastrophiques - `app/[locale]/error.tsx` : Erreurs par locale - `app/[locale]/dashboard/error.tsx` : Erreurs spécifiques au dashboard **Avantage :** Isolation des erreurs et gestion contextuelle. #### ✅ 6. Extraction des Messages Backend ```typescript // src/lib/api/responseMessages.ts export function extractErrorMessage( error: unknown, fallback: string = 'Une erreur inattendue est survenue.' ): string { if (isAxiosError(error) && error.response?.data) { const data = error.response.data; // Priorité aux messages FR if (typeof data.messageFR === 'string' && data.messageFR) { return data.messageFR; } // Fallback sur messageEN if (typeof data.messageEN === 'string' && data.messageEN) { return data.messageEN; } // Autres formats... } return fallback; } ``` **Avantage :** Supporte la localisation et gère plusieurs formats de réponse. --- ## 7. Problèmes Identifiés et Solutions ### 🔴 PROBLÈME CRITIQUE 1 : Services Sans Gestion d'Erreurs **Localisation :** `src/services/courses/coursesService.ts`, `src/services/modules/modulesService.ts` **Description :** Les services n'ont aucun try/catch. Les erreurs remontent directement aux hooks sans classification ni contexte. **Code actuel :** ```typescript async getAllCourses(): Promise { const response = await this.get(`/cours`); if (!response || !Array.isArray(response)) { return []; // ❌ Masque les vraies erreurs } return response.map(res => CoursesMapper.mapApiCourseToCourseEntity(res)); } ``` **Problèmes :** 1. Impossible de distinguer une réponse vide d'une erreur réseau 2. Pas de logging des erreurs au niveau service 3. Pas de contexte ajouté (méthode, paramètres) 4. Les erreurs ne sont pas classifiées avant d'atteindre le hook **Solution :** ```typescript async getAllCourses(): Promise { try { const response = await this.get(`/cours`); // Validation de la réponse if (!response || !Array.isArray(response)) { throw new Error('Invalid response format from API'); } return response.map(res => CoursesMapper.mapApiCourseToCourseEntity(res)); } catch (error) { // Classifier l'erreur const classified = classifyError(error); // Logger avec contexte ErrorLogger.log(classified, { service: 'CoursesService', method: 'getAllCourses', timestamp: new Date().toISOString() }); // Re-throw pour que les hooks puissent gérer throw classified; } } async getCourseById(id: string): Promise { try { const response = await this.get(`/cours/${id}`); if (!response) { throw new NotFoundError(`Course with id ${id} not found`); } return CoursesMapper.mapApiCourseToCourseEntity(response); } catch (error) { // Si c'est une 404, lancer une erreur spécifique if (error instanceof HttpError && error.status === 404) { throw new NotFoundError(`Course ${id} not found`); } const classified = classifyError(error); ErrorLogger.log(classified, { service: 'CoursesService', method: 'getCourseById', courseId: id }); throw classified; } } ``` **Impact :** - Meilleure traçabilité des erreurs - Messages plus précis pour l'utilisateur - Facilite le debugging --- ### 🔴 PROBLÈME CRITIQUE 2 : Erreurs de Query Ignorées dans les Components **Localisation :** Tous les components utilisant `useQuery` **Description :** Les hooks retournent `error` mais les components ne l'utilisent jamais. L'utilisateur ne voit rien en cas d'échec. **Code actuel :** ```typescript // Hook const { data: course, error: courseError, isLoading } = useQuery({ queryKey: ['course_by_id', courseId], queryFn: async () => coursesService.getCourseById(courseId), enabled: !!courseId, }); // Component if (isCourseLoading) { return
Chargement...
; } // ❌ Pas de gestion de courseError // Si courseError existe, course sera undefined mais rien n'est affiché return ; // ❌ Crash si course est undefined ``` **Problèmes :** 1. Utilisateur voit un spinner infini ou un écran blanc 2. Impossible de savoir si le chargement a échoué 3. Pas de possibilité de retry 4. Mauvaise expérience utilisateur **Solution :** ```typescript // Hook avec gestion d'erreurs export function useCourse(courseId: string) { return useQuery({ queryKey: ['course_by_id', courseId], queryFn: async () => coursesService.getCourseById(courseId), enabled: !!courseId, retry: (failureCount, error) => { // Ne pas retry les 404 if (error instanceof NotFoundError) { return false; } return failureCount < 3; }, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), onError: (error) => { const classified = classifyError(error); // Log l'erreur ErrorLogger.log(classified, { hook: 'useCourse', courseId }); // Toast seulement pour erreurs graves if (classified.severity === ErrorSeverity.FATAL) { toastService.error({ title: 'Erreur de chargement', fallbackDescription: 'Impossible de charger le cours' }, error); } } }); } // Component avec gestion complète export function CoursePage({ courseId }: Props) { const { data: course, isLoading, error, refetch } = useCourse(courseId); // Loading state if (isLoading) { return ; } // Error state if (error) { const classified = classifyError(error); // 404 : Cours non trouvé if (classified.category === ErrorCategory.NOT_FOUND) { return ( } title="Cours introuvable" description="Le cours que vous recherchez n'existe pas ou a été supprimé." action={ } /> ); } // Autres erreurs return ( ); } // Empty state (ne devrait pas arriver) if (!course) { return ; } // Success state return ; } ``` **Impact :** - Utilisateur informé en cas d'erreur - Possibilité de retry - Meilleure UX --- ### 🔴 PROBLÈME CRITIQUE 3 : Pas de Mapping des Erreurs de Validation Backend **Localisation :** Tous les formulaires **Description :** La fonction `extractValidationErrors()` existe mais n'est jamais utilisée. Les erreurs de validation backend ne sont pas mappées aux champs du formulaire. **Code actuel :** ```typescript // useContact.ts catch (err) { toastService.error({ title: "Erreur d'envoi" }, err); setError("Une erreur est survenue lors de l'envoi du message."); // ❌ Les erreurs de champs spécifiques sont perdues } ``` **Problème :** Si le backend retourne : ```json { "statusCode": 422, "errors": { "email": ["Email invalide", "Email déjà utilisé"], "password": ["Minimum 8 caractères"] } } ``` L'utilisateur voit seulement "Une erreur est survenue", pas les détails. **Solution :** ```typescript // Hook amélioré export function useLogin() { const { setError } = useFormContext(); // React Hook Form return useMutation({ mutationFn: (payload: LoginPayload) => authService.login(payload), onError: (error) => { // Erreurs de validation if (error instanceof HttpError && error.status === 422) { const validationErrors = extractValidationErrors(error); if (validationErrors) { // Mapper chaque erreur au champ correspondant Object.entries(validationErrors).forEach(([field, messages]) => { setError(field as keyof LoginPayload, { type: 'server', message: messages[0] // Prendre le premier message }); }); // Toast général toastService.error({ title: 'Formulaire invalide', fallbackDescription: 'Veuillez corriger les erreurs dans le formulaire' }); return; // Ne pas continuer } } // Autres erreurs toastService.error({ title: 'Erreur de connexion', fallbackDescription: 'Impossible de se connecter' }, error); } }); } // Fonction utilitaire réutilisable export function useBackendValidation() { const { setError } = useFormContext(); const mapBackendErrors = (error: unknown) => { if (error instanceof HttpError && error.status === 422) { const validationErrors = extractValidationErrors(error); if (validationErrors) { Object.entries(validationErrors).forEach(([field, messages]) => { setError(field as Path, { type: 'server', message: messages[0] }); }); return true; } } return false; }; return { mapBackendErrors }; } // Utilisation export function LoginForm() { const form = useForm({ resolver: zodResolver(loginSchema) }); const { mapBackendErrors } = useBackendValidation(); const { mutate: login, isLoading } = useLogin(); const onSubmit = async (data: LoginFormData) => { try { await login(data); } catch (error) { // Mapper les erreurs backend aux champs if (!mapBackendErrors(error)) { // Si ce n'est pas une erreur de validation, toast générique toastService.error({ title: 'Erreur', fallbackDescription: 'Une erreur est survenue' }, error); } } }; return (
{form.formState.errors.email && ( {form.formState.errors.email.message} )}
{form.formState.errors.password && ( {form.formState.errors.password.message} )}
); } ``` **Impact :** - Erreurs affichées au bon endroit (sous chaque champ) - Meilleure guidance pour l'utilisateur - Validation cohérente frontend/backend --- ### 🔴 PROBLÈME CRITIQUE 4 : Monitoring Non Intégré **Localisation :** `src/lib/errors/errorLogger.ts` **Description :** Le code est préparé pour Sentry mais commenté. En production, les erreurs ne sont pas envoyées à un service de monitoring. **Code actuel :** ```typescript // TODO: Send to monitoring service (Sentry) if (process.env.NODE_ENV === 'production') { // Sentry.captureException(error.originalError || new Error(error.message), { // extra: context // }); } ``` **Problèmes :** 1. Impossible de détecter les erreurs en production 2. Pas de métriques ni de tendances 3. Debugging difficile sans logs centralisés 4. Pas d'alertes automatiques **Solution :** ```typescript // 1. Installer Sentry // npm install @sentry/nextjs // 2. Configurer Sentry // sentry.client.config.ts import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, tracesSampleRate: 1.0, // Ignorer certaines erreurs ignoreErrors: ['Network request failed', 'ResizeObserver loop limit exceeded'], // Filtrer les données sensibles beforeSend(event, hint) { // Supprimer les tokens if (event.request?.headers) { delete event.request.headers['Authorization']; } return event; }, }); // 3. Mettre à jour ErrorLogger export class ErrorLogger { static log(error: AppError, context: ErrorContext = {}): void { // Rate limiting... // Console this.logToConsole(error, context); // LocalStorage this.logToStorage(error, context); // Monitoring this.logToMonitoring(error, context); } private static logToMonitoring(error: AppError, context: ErrorContext): void { if (process.env.NODE_ENV !== 'production') { return; } // Envoyer à Sentry Sentry.captureException(error.originalError || new Error(error.message), { level: this.getSentryLevel(error.severity), tags: { category: error.category, errorCode: error.status?.toString(), }, extra: { ...context, appError: error, }, fingerprint: [error.category, error.message], }); // Métriques personnalisées if (error.category === ErrorCategory.AUTH) { Sentry.addBreadcrumb({ category: 'auth', message: 'Authentication error occurred', level: 'error', }); } } private static getSentryLevel(severity: ErrorSeverity): Sentry.SeverityLevel { switch (severity) { case ErrorSeverity.FATAL: return 'fatal'; case ErrorSeverity.HIGH: return 'error'; case ErrorSeverity.MEDIUM: return 'warning'; case ErrorSeverity.LOW: return 'info'; } } } // 4. Configurer les Error Boundaries avec Sentry import * as Sentry from '@sentry/nextjs'; export class ErrorBoundary extends Component { componentDidCatch(error: Error, errorInfo: ErrorInfo) { // Log à Sentry avec contexte React Sentry.withScope(scope => { scope.setContext('react', { componentStack: errorInfo.componentStack, }); Sentry.captureException(error); }); // Log local const classified = classifyError(error); ErrorLogger.log(classified, { componentStack: errorInfo.componentStack, boundary: 'ErrorBoundary', }); } } // 5. Instrumenter les requêtes API import { sentryTracing } from '@sentry/nextjs'; export class HttpClient { async get(url: string): Promise { // Créer une transaction Sentry const transaction = Sentry.startTransaction({ name: `GET ${url}`, op: 'http.client', }); try { const response = await this.client.get(url); transaction.setStatus('ok'); return response.data; } catch (error) { transaction.setStatus('internal_error'); throw error; } finally { transaction.finish(); } } } ``` **Configuration Sentry recommandée :** ```typescript // next.config.js const { withSentryConfig } = require('@sentry/nextjs'); module.exports = withSentryConfig( { // Next.js config }, { // Sentry config silent: true, org: 'your-org', project: 'asf-frontend', widenClientFileUpload: true, hideSourceMaps: true, disableLogger: true, } ); ``` **Alertes à configurer :** 1. **Taux d'erreur élevé** : > 5% des requêtes 2. **Erreurs critiques** : Severity FATAL 3. **Nouvelle erreur** : Erreur jamais vue avant 4. **Régression** : Augmentation soudaine d'un type d'erreur **Impact :** - Détection proactive des problèmes - Métriques et tendances - Debugging facilité avec stack traces - Alertes automatiques --- ### 🟠 PROBLÈME MAJEUR 5 : Pas de Retry Logic **Localisation :** `src/core/http/httpClient.ts`, hooks React Query **Description :** Aucune stratégie de retry avec exponential backoff pour les erreurs réseau transitoires. **Code actuel :** ```typescript // React Query avec retry par défaut (1 fois) useQuery({ queryKey: ['courses'], queryFn: () => coursesService.getAllCourses(), // ❌ Retry par défaut : 1 fois seulement }); ``` **Problème :** Les erreurs réseau transitoires (timeouts, connexion perdue temporairement) font échouer les requêtes alors qu'un simple retry résoudrait le problème. **Solution :** ```typescript // 1. Configurer React Query globalement // lib/react-query/queryClient.ts import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { const classified = classifyError(error); // Ne jamais retry les erreurs client (4xx) if (classified.category === ErrorCategory.VALIDATION || classified.category === ErrorCategory.AUTH || classified.category === ErrorCategory.NOT_FOUND) { return false; } // Retry jusqu'à 3 fois pour les erreurs serveur/réseau if (classified.category === ErrorCategory.NETWORK || classified.category === ErrorCategory.SERVER || classified.category === ErrorCategory.TIMEOUT) { return failureCount < 3; } return false; }, retryDelay: (attemptIndex) => { // Exponential backoff: 1s, 2s, 4s // Avec jitter pour éviter les thundering herds const baseDelay = Math.min(1000 * 2 ** attemptIndex, 30000); const jitter = Math.random() * 1000; return baseDelay + jitter; }, staleTime: 5 * 60 * 1000, // 5 minutes cacheTime: 10 * 60 * 1000, // 10 minutes }, mutations: { // Pas de retry pour les mutations par défaut retry: false } } }); // 2. Retry manuel pour les mutations critiques export function useEnrollCourse() { const [retryCount, setRetryCount] = useState(0); const maxRetries = 3; const mutation = useMutation({ mutationFn: async (courseId: string) => { return await coursesService.enrollCourse(courseId); }, onError: async (error, variables) => { const classified = classifyError(error); // Retry automatique pour erreurs réseau if (classified.category === ErrorCategory.NETWORK && retryCount < maxRetries) { const delay = 1000 * 2 ** retryCount; await sleep(delay); setRetryCount(prev => prev + 1); mutation.mutate(variables); // Retry } else { // Échec définitif toastService.error({ title: 'Erreur d\'inscription', fallbackDescription: 'Impossible de vous inscrire' }, error); setRetryCount(0); } }, onSuccess: () => { setRetryCount(0); toastService.success({ title: 'Inscription réussie' }); } }); return mutation; } // 3. Wrapper de retry générique export async function retryWithBackoff( fn: () => Promise, options: { maxRetries?: number; baseDelay?: number; shouldRetry?: (error: unknown) => boolean; onRetry?: (attempt: number, error: unknown) => void; } = {} ): Promise { const { maxRetries = 3, baseDelay = 1000, shouldRetry = (error) => { const classified = classifyError(error); return classified.category === ErrorCategory.NETWORK || classified.category === ErrorCategory.SERVER || classified.category === ErrorCategory.TIMEOUT; }, onRetry } = options; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; // Vérifier si on doit retry if (attempt < maxRetries && shouldRetry(error)) { const delay = baseDelay * Math.pow(2, attempt); const jitter = Math.random() * 1000; onRetry?.(attempt + 1, error); await sleep(delay + jitter); } else { break; } } } throw lastError; } // Utilisation dans un service async getAllCourses(): Promise { return await retryWithBackoff( async () => { const response = await this.get('/cours'); if (!response || !Array.isArray(response)) { throw new Error('Invalid response format'); } return response.map(course => CoursesMapper.mapApiCourseToCourseEntity(course)); }, { maxRetries: 3, baseDelay: 1000, onRetry: (attempt, error) => { console.log(`Retry attempt ${attempt} for getAllCourses`, error); } } ); } ``` **Impact :** - Meilleure résilience aux erreurs transitoires - Moins d'échecs pour l'utilisateur - Expérience plus fluide --- ### 🟠 PROBLÈME MAJEUR 6 : Timeouts Non Configurés **Localisation :** `src/core/http/httpClient.ts` **Description :** Pas de timeout configuré pour les requêtes HTTP. Les requêtes peuvent pendre indéfiniment. **Solution :** ```typescript export class HttpClient { private client: AxiosInstance; constructor(baseURL: string) { this.client = axios.create({ baseURL, timeout: 30000, // 30 secondes headers: { 'Content-Type': 'application/json', }, }); this.setupInterceptors(); } // Timeouts spécifiques par endpoint async uploadFile(file: File): Promise { // Upload peut prendre plus de temps return await this.client.post('/upload', file, { timeout: 120000, // 2 minutes }); } async search(query: string): Promise { // Search doit être rapide return await this.client.get('/search', { params: { q: query }, timeout: 5000, // 5 secondes }); } } ``` **Impact :** - Prévient les requêtes infinies - Meilleure UX (échec rapide plutôt qu'attente) --- ### 🟡 PROBLÈME MINEUR 7 : Détection d'Endpoints Auth Fragile **Localisation :** `src/core/http/httpClient.ts` **Code actuel :** ```typescript private isAuthEndpoint(url: string): boolean { return url.includes('/auth/') || url.endsWith('/login') || url.endsWith('/register'); } ``` **Problème :** Utilise des regex/string matching qui peuvent avoir des faux positifs/négatifs. **Solution :** ```typescript // Liste exhaustive des endpoints auth private readonly AUTH_ENDPOINTS = new Set([ '/auth/login', '/auth/register', '/auth/refresh', '/auth/logout', '/auth/reset-password', '/auth/verify-email' ]); private isAuthEndpoint(url: string): boolean { // Normaliser l'URL const normalizedUrl = url.startsWith('/') ? url : `/${url}`; // Vérifier correspondance exacte return this.AUTH_ENDPOINTS.has(normalizedUrl); } // Ou utiliser un pattern plus robuste private isAuthEndpoint(url: string): boolean { const authPattern = /^\/auth\/(login|register|refresh|logout|reset-password|verify-email)$/; return authPattern.test(url); } ``` **Impact :** - Évite les bugs lors du refresh de token - Plus maintenable --- ### 🟡 PROBLÈME MINEUR 8 : Erreurs LocalStorage Ignorées **Localisation :** `src/lib/errors/errorLogger.ts` **Code actuel :** ```typescript private static logToStorage(error: AppError, context: ErrorContext): void { try { const logs = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '[]'); // ... localStorage.setItem(this.STORAGE_KEY, JSON.stringify(logs)); } catch (e) { // ❌ Erreur ignorée silencieusement } } ``` **Problème :** Si localStorage est plein ou inaccessible, l'erreur est perdue. **Solution :** ```typescript private static logToStorage(error: AppError, context: ErrorContext): void { try { const logs = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '[]'); logs.push({ error, context, timestamp: new Date().toISOString() }); // Garder seulement les 50 derniers if (logs.length > 50) { logs.shift(); } localStorage.setItem(this.STORAGE_KEY, JSON.stringify(logs)); } catch (storageError) { // Fallback : essayer de libérer de l'espace try { localStorage.clear(); localStorage.setItem(this.STORAGE_KEY, JSON.stringify([{ error, context }])); } catch (finalError) { // Dernier recours : log en console seulement console.warn('Failed to log to localStorage:', finalError); console.error('Original error:', error); } } } ``` **Impact :** - Logs plus fiables - Gestion des cas limites --- ## 8. Bonnes Pratiques et Recommandations ### 8.1 Checklist de Gestion d'Erreurs #### Pour chaque Couche : **✅ Service Layer** - [ ] Try/catch autour des appels API - [ ] Classification des erreurs - [ ] Logging avec contexte - [ ] Re-throw pour les couches supérieures - [ ] Validation des réponses **✅ Hook Layer** - [ ] Configuration de retry appropriée - [ ] Gestion des erreurs dans onError - [ ] Toast pour erreurs critiques - [ ] Invalidation de cache si nécessaire **✅ Component Layer** - [ ] Affichage des états de loading - [ ] Affichage des erreurs - [ ] Bouton de retry si applicable - [ ] Empty states **✅ Global** - [ ] Error boundaries à tous les niveaux - [ ] Monitoring intégré (Sentry) - [ ] Rate limiting des logs - [ ] Messages localisés ### 8.2 Patterns Recommandés #### Pattern 1 : Error-First Callback ```typescript async function processData( data: unknown, onSuccess: (result: Result) => void, onError: (error: AppError) => void ) { try { const result = await doSomething(data); onSuccess(result); } catch (error) { const classified = classifyError(error); ErrorLogger.log(classified); onError(classified); } } ``` #### Pattern 2 : Result Type ```typescript type Result = { success: true; data: T } | { success: false; error: E }; async function getCourse(id: string): Promise> { try { const course = await coursesService.getCourseById(id); return { success: true, data: course }; } catch (error) { return { success: false, error: classifyError(error) }; } } // Utilisation const result = await getCourse('123'); if (result.success) { console.log(result.data); } else { console.error(result.error); } ``` #### Pattern 3 : Custom Hooks pour Error Handling ```typescript // useErrorHandler.ts export function useErrorHandler() { const handleError = useCallback((error: unknown, context?: string) => { const classified = classifyError(error); ErrorLogger.log(classified, { context }); // Toast selon sévérité if (classified.severity >= ErrorSeverity.MEDIUM) { toastService.error({ title: 'Erreur', fallbackDescription: classified.message, }); } // Redirection si auth if (classified.category === ErrorCategory.AUTH) { router.push('/auth/login'); } }, []); return { handleError }; } // Utilisation const { handleError } = useErrorHandler(); try { await someOperation(); } catch (error) { handleError(error, 'someOperation'); } ``` ### 8.3 Messages d'Erreur Efficaces #### ❌ Mauvais Messages ``` "Error" "Something went wrong" "Error 500" "Exception occurred" "Undefined" ``` #### ✅ Bons Messages ``` "Impossible de se connecter au serveur. Vérifiez votre connexion internet." "L'email que vous avez entré est déjà utilisé. Connectez-vous ou utilisez un autre email." "Le cours est complet. Vous pouvez rejoindre la liste d'attente." "Votre session a expiré. Veuillez vous reconnecter." ``` **Règles :** 1. **Soyez spécifique** : Qu'est-ce qui s'est mal passé ? 2. **Soyez actionnable** : Que peut faire l'utilisateur ? 3. **Soyez humain** : Langage simple et empathique 4. **Évitez le jargon** : Pas de codes ou termes techniques ### 8.4 Testing de la Gestion d'Erreurs ```typescript // __tests__/errorHandling.test.ts describe('Error Handling', () => { it('should display error message when API fails', async () => { // Mock l'API pour qu'elle échoue jest.spyOn(coursesService, 'getAllCourses').mockRejectedValue( new HttpError('Network error', 0) ); // Render le component render(); // Attendre que l'erreur s'affiche await waitFor(() => { expect(screen.getByText(/impossible de charger/i)).toBeInTheDocument(); }); // Vérifier le bouton retry expect(screen.getByText(/réessayer/i)).toBeInTheDocument(); }); it('should retry on network error', async () => { const mockFn = jest.fn() .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce([course1, course2]); jest.spyOn(coursesService, 'getAllCourses').mockImplementation(mockFn); render(); // Attendre le retry automatique await waitFor(() => { expect(mockFn).toHaveBeenCalledTimes(2); }); // Vérifier que les cours s'affichent expect(screen.getByText(course1.title)).toBeInTheDocument(); }); it('should map backend validation errors to form fields', async () => { const validationError = new HttpError('Validation failed', 422, { errors: { email: ['Email invalide'], password: ['Trop court'] } }); jest.spyOn(authService, 'login').mockRejectedValue(validationError); const { user } = render(); // Remplir et soumettre await user.type(screen.getByLabelText(/email/i), 'bad-email'); await user.type(screen.getByLabelText(/password/i), '123'); await user.click(screen.getByRole('button', { name: /connexion/i })); // Vérifier que les erreurs sont affichées await waitFor(() => { expect(screen.getByText('Email invalide')).toBeInTheDocument(); expect(screen.getByText('Trop court')).toBeInTheDocument(); }); }); }); ``` --- ## 9. Exemples Pratiques et Patterns ### 9.1 Exemple Complet : Page de Cours ```typescript // pages/cours/[id].tsx export default function CoursePage({ params }: { params: { id: string } }) { const { data: course, isLoading, error, refetch } = useCourse(params.id); const { mutate: enroll, isLoading: isEnrolling } = useEnrollCourse(); const handleEnroll = () => { enroll(params.id); }; // Loading if (isLoading) { return (
); } // Error if (error) { const classified = classifyError(error); if (classified.category === ErrorCategory.NOT_FOUND) { return (
} title="Cours introuvable" description="Le cours que vous recherchez n'existe pas ou a été supprimé." action={ } />
); } return (
); } // Success if (!course) { return null; } return (
); } ``` ### 9.2 Exemple : Formulaire avec Validation Complète ```typescript // components/forms/ContactForm.tsx const contactSchema = z.object({ name: z.string().min(2, 'Minimum 2 caractères'), email: z.string().email('Email invalide'), subject: z.string().min(5, 'Minimum 5 caractères'), message: z.string().min(10, 'Minimum 10 caractères') }); type ContactFormData = z.infer; export function ContactForm() { const form = useForm({ resolver: zodResolver(contactSchema), defaultValues: { name: '', email: '', subject: '', message: '' } }); const { mapBackendErrors } = useBackendValidation(); const { mutate: submit, isLoading } = useSubmitContact(); const onSubmit = async (data: ContactFormData) => { try { await submit(data); // Reset form on success form.reset(); toastService.success({ title: 'Message envoyé', description: 'Nous vous répondrons dans les plus brefs délais' }); } catch (error) { // Mapper erreurs backend if (!mapBackendErrors(error)) { // Erreur non-validation toastService.error({ title: 'Erreur d\'envoi', fallbackDescription: 'Impossible d\'envoyer votre message' }, error); } } }; return (
{form.formState.errors.name && ( {form.formState.errors.name.message} )}
{form.formState.errors.email && ( {form.formState.errors.email.message} )}
{form.formState.errors.subject && ( {form.formState.errors.subject.message} )}