Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 68

Gestion Centralisée des Erreurs avec @ControllerAdvice et @ExceptionHandler

Recommended resources

Bonjour ! Dans la leçon précédente, nous avons refactorisé notre API pour utiliser des DTOs, ce qui a permis de découpler notre modèle de données de notre contrat d'API. Nous avions conclu en notant que notre gestion d'erreur pour un livre non trouvé, via orElseThrow(() -> new RuntimeException(...)), était fonctionnelle mais peu professionnelle. Le client recevait une erreur 500 Internal Server Error générique, ce qui est à la fois imprécis et peu informatif.

Aujourd'hui, nous allons corriger cela en abordant une pratique essentielle pour toute API REST robuste : la gestion centralisée des exceptions. L'objectif est de mettre en place un mécanisme qui intercepte les erreurs de manière globale et retourne des réponses claires, standardisées et avec le code de statut HTTP approprié (par exemple, un 404 Not Found au lieu d'un 500). Pour ce faire, nous allons découvrir deux annotations puissantes de Spring : @ControllerAdvice et @ExceptionHandler.

1. Le problème : des erreurs mal communiquées

Une bonne API ne se contente pas de bien fonctionner dans les cas nominaux ; elle doit aussi se comporter de manière prévisible et utile en cas d'erreur. Envoyer le mauvais code de statut HTTP ou un message d'erreur cryptique peut rendre le débogage très difficile pour les développeurs qui consomment votre API.

L'article suivant de Sfeir explique très bien pourquoi il est crucial de respecter la sémantique des codes HTTP.

Comment bien gérer ses exceptions dans Spring Boot - sfeir.dev

Cet article pose les bases théoriques de la gestion d'erreurs dans une API REST.

Lisez la première partie de l'article, jusqu'à la section "Pourquoi bien gérer ses erreurs ?". Portez une attention particulière au tableau des codes HTTP et à l'explication de pourquoi un code 200 OK avec un message d'erreur est une "terrible" réponse.

Pour voir concrètement le problème dans une application Spring Boot, la vidéo ci-dessous montre ce qui se passe par défaut lorsqu'une ressource n'est pas trouvée ou qu'une autre exception survient.

Spring Boot Tutorial : @ControllerAdvice ou comment gérer les exceptions

Cette vidéo de "Coding With Armand" illustre parfaitement les réponses d'erreur par défaut de Spring Boot.

Regardez l'introduction et la démonstration. L'auteur montre deux cas : une URL incorrecte (où Spring retourne un message d'erreur JSON structuré) et une ressource non trouvée (où l'API retourne un statut 404 mais avec un corps de réponse vide). C'est ce dernier cas que nous voulons améliorer.

Notre objectif est donc de prendre le contrôle total sur les réponses d'erreur pour qu'elles soient toujours structurées, informatives et cohérentes.


2. La solution : @ControllerAdvice et @ExceptionHandler

Plutôt que de polluer chaque méthode de nos contrôleurs avec des blocs try-catch (ce qui violerait le principe de responsabilité unique), Spring nous offre une solution élégante et centralisée.

  • @ControllerAdvice : C'est une annotation qui permet de créer une classe "conseiller" globale. Cette classe peut intercepter des exceptions, modifier des modèles de données ou lier des données avant l'exécution des méthodes de contrôleurs, et ce, à travers toute l'application.
  • @ExceptionHandler : Placée sur une méthode à l'intérieur d'une classe @ControllerAdvice, cette annotation indique que la méthode est destinée à gérer un type d'exception spécifique.

Le flux est le suivant :

  1. Une exception est levée quelque part dans la couche Service ou Controller.
  2. Spring intercepte cette exception.
  3. Il recherche une méthode capable de la gérer dans une classe @ControllerAdvice.
  4. Si une méthode correspondante est trouvée (via @ExceptionHandler), elle est exécutée.
  5. Cette méthode construit et retourne une ResponseEntity personnalisée, qui devient la réponse HTTP envoyée au client.

Le diagramme ci-dessous illustre parfaitement ce mécanisme.

Ce schéma montre comment une requête client (via Postman) déclenche une exception dans la couche Service. Au lieu de "planter", l'exception est capturée par le `GlobalExceptionHandler` (notre future classe `@ControllerAdvice`), qui formate une réponse d'erreur appropriée et la renvoie au client.

3. Mise en œuvre étape par étape

Nous allons maintenant appliquer ce pattern à notre projet de gestion de livres.

Étape 1 : Créer un DTO pour les réponses d'erreur

Tout comme nous avons utilisé des DTOs pour nos données métier, nous allons en créer un pour structurer nos messages d'erreur. C'est une excellente pratique pour assurer la cohérence de votre API.

Dans votre package dto, créez un record Java nommé ErrorDto.java.

package com.votreprojet.dto;

import java.time.LocalDateTime;

public record ErrorDto(
    int status,
    String message,
    LocalDateTime timestamp
) {}

Ce record simple contiendra le code de statut HTTP, un message clair pour l'utilisateur, et l'heure à laquelle l'erreur s'est produite.

Étape 2 : Créer une exception personnalisée

Pour rendre notre code plus expressif, nous allons créer notre propre classe d'exception pour les ressources non trouvées. Cela nous permettra de la distinguer des autres RuntimeException.

Créez un package exception et ajoutez-y la classe ResourceNotFoundException.java.

package com.votreprojet.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

// @ResponseStatus(value = HttpStatus.NOT_FOUND) // Optionnel : une autre façon de mapper au code 404
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Hériter de RuntimeException en fait une exception "non vérifiée" (unchecked), ce qui signifie que nous n'avons pas besoin d'ajouter throws ResourceNotFoundException à toutes nos signatures de méthode, allégeant ainsi le code.

Étape 3 : Mettre à jour le BookService

Modifions la méthode getBookById dans BookService.java pour qu'elle lève notre nouvelle exception personnalisée.

// Dans BookService.java
import com.votreprojet.exception.ResourceNotFoundException;

// ...

public BookDto getBookById(Long id) {
    Book book = bookRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Book not found with id: " + id)); // Modification ici
    return bookMapper.toDto(book);
}

// Faites de même pour la méthode updateBook et deleteBook si elles contiennent une logique similaire.

À ce stade, si vous testez, Spring lèvera toujours une erreur 500, car il ne sait pas encore comment traduire notre ResourceNotFoundException en une réponse 404. C'est l'objet de la dernière étape.

Étape 4 : Créer le GlobalExceptionHandler

C'est ici que la magie opère. Créez une nouvelle classe GlobalExceptionHandler.java dans le package exception.

Spring Boot Tutorial : @ControllerAdvice ou comment gérer les exceptions

La vidéo "Coding With Armand" explique en détail comment créer le ControllerAdvice et la méthode ExceptionHandler.

Regardez attentivement la section qui implémente le CustomExceptionHandler. Concentrez-vous sur l'utilisation des annotations @ControllerAdvice et @ExceptionHandler, la construction de l'objet ApiError (notre ErrorDto), et la manière de retourner une ResponseEntity avec le bon statut et le bon corps.

Inspirés par la vidéo et les articles, voici le code pour notre GlobalExceptionHandler.java :

package com.votreprojet.exception;

import com.votreprojet.dto.ErrorDto;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

import java.time.LocalDateTime;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorDto> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
        ErrorDto errorDto = new ErrorDto(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return new ResponseEntity<>(errorDto, HttpStatus.NOT_FOUND);
    }
}

Analyse du code :

  • @ControllerAdvice : Déclare cette classe comme un "conseiller" global pour les contrôleurs.
  • @ExceptionHandler(ResourceNotFoundException.class) : Spécifie que cette méthode handleResourceNotFoundException ne sera appelée que lorsque une exception de type ResourceNotFoundException (ou une de ses sous-classes) est levée.
  • ResponseEntity<ErrorDto> : Le type de retour indique que nous contrôlons entièrement la réponse HTTP, y compris le corps (notre ErrorDto) et les en-têtes/status.
  • new ResponseEntity<>(errorDto, HttpStatus.NOT_FOUND) : Nous construisons la réponse avec notre objet DTO d'erreur et le statut HTTP 404 Not Found.

Relancez votre application et testez à nouveau GET /books/{id} avec un ID qui n'existe pas dans Postman. Vous devriez maintenant recevoir une réponse 404 Not Found propre avec un corps JSON comme celui-ci :

{
    "status": 404,
    "message": "Book not found with id: 999",
    "timestamp": "2023-10-27T10:30:00.123456"
}

4. Gérer les autres types d'erreurs

Un bon gestionnaire d'exceptions doit aussi avoir un "filet de sécurité" pour les erreurs imprévues. Nous pouvons ajouter une méthode pour gérer n'importe quelle Exception générique. Cela garantit que même si une erreur inattendue se produit, le client ne recevra jamais une page d'erreur HTML par défaut, mais toujours une réponse JSON structurée.

Spring Boot Tutorial : @ControllerAdvice ou comment gérer les exceptions

La fin de la vidéo montre comment étendre le ControllerAdvice pour gérer d'autres types d'exceptions.

Regardez comment l'auteur ajoute un deuxième ExceptionHandler pour intercepter les Exception génériques, renvoyant cette fois un statut 500 Internal Server Error.

Ajoutez cette méthode à votre GlobalExceptionHandler.java :

// Dans GlobalExceptionHandler.java

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorDto> handleGlobalException(Exception ex, WebRequest request) {
        ErrorDto errorDto = new ErrorDto(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred: " + ex.getMessage(), // Soyez prudent avec ce que vous exposez ici
                LocalDateTime.now()
        );
        return new ResponseEntity<>(errorDto, HttpStatus.INTERNAL_SERVER_ERROR);
    }

Bonne pratique : En production, il est souvent déconseillé d'exposer directement le message de l'exception (ex.getMessage()) pour les erreurs 500, car cela pourrait révéler des détails d'implémentation. On préfère souvent un message générique et on logue l'exception complète côté serveur pour analyse.


Conclusion

Félicitations ! Vous avez transformé la gestion des erreurs de votre API, la faisant passer d'un comportement par défaut à une solution professionnelle, robuste et centralisée.

Points clés à retenir :

  • Centralisation : L'annotation @ControllerAdvice permet de regrouper toute la logique de gestion des exceptions en un seul endroit.
  • Spécificité : L'annotation @ExceptionHandler permet de créer des gestionnaires sur mesure pour chaque type d'exception, retournant des statuts et des messages appropriés.
  • Clarté : L'utilisation d'exceptions personnalisées (ResourceNotFoundException) rend le code métier plus lisible et intentionnel.
  • Cohérence : L'emploi d'un DTO d'erreur (ErrorDto) garantit que toutes les réponses d'erreur de votre API auront la même structure.

Maintenant que notre API sait gérer proprement les données non trouvées, une autre question se pose : comment empêcher les données invalides d'être insérées en premier lieu ? Par exemple, comment s'assurer que le titre d'un livre n'est jamais vide lors d'une requête de création ?

Dans la prochaine leçon, nous aborderons la validation des données d'entrée en utilisant les annotations de validation de Jakarta EE comme @Valid, @NotNull et @Size. Cela nous permettra de rejeter les requêtes invalides à la porte d'entrée de notre API, avant même d'atteindre notre logique métier.