Gestion personnalisée des exceptions dans Spring Boot via HandlerExceptionResolver

Introduction à la gestion des exceptions via HandlerExceptionResolver

Dans les applications web Spring Boot, la gestion globale des exceptions est généralement assurée par l'annotation @ControllerAdvice couplée à @ExceptionHandler. Cependant, le framework Spring MVC offre une alternative moins conventionnelle mais tout à fait fonctionnelle : l'implémentation de l'interface HandlerExceptionResolver. Cet article explore cette approche, son implémentation, et ses particularités, notamment concernant les erreurs HTTP 404.

Configuration du projet

Pour expérimenter cette fonctionnalité, nous devons initialiser une application web Spring Boot standard. Voici une configuration Maven mise à jour et simplifiée :

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.14</version>
    <relativePath/>
</parent>

<properties>
    <java.version>11</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

Implémentation de HandlerExceptionResolver

1. Création du résolveur personnalisé

L'interface HandlerExceptionResolver ne définit qu'une seule méthode : resolveException. Celle-ci est invoquée lorsqu'nue exception non capturée survient durant le traitement d'une requête. Elle fournit l'objet Exception ainsi que les objets de requête et de réponse.

public class CustomGlobalExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest req, HttpServletResponse res, 
                                         Object handler, Exception exception) {
        
        res.setHeader("Content-Type", "application/json; charset=UTF-8");
        res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);

        String stackTrace = extractStackTrace(exception);
        String jsonResponse = String.format("{\"error\": \"%s\", \"trace\": \"%s\"}", 
                                            exception.getMessage(), stackTrace.replace("\"", "\\\""));

        try (PrintWriter writer = res.getWriter()) {
            writer.write(jsonResponse);
            writer.flush();
        } catch (IOException ioEx) {
            ioEx.printStackTrace();
        }
        
        // Retourner null indique que la réponse a été entièrement gérée
        return null; 
    }

    private String extractStackTrace(Throwable throwable) {
        StringWriter stringWriter = new StringWriter();
        throwable.printStackTrace(new PrintWriter(stringWriter));
        return stringWriter.toString().replace("\n", " | ").replace("\r", "");
    }
}

Points clés de cette implémentation :

  • Encodage et Type de contenu : Nous définissons explicitement le type de contenu en JSON et l'encodage UTF-8 pour éviter tout problème de caractères spéciaux.
  • Écriture directe : Au lieu de retourner un ModelAndView pour une vue JSP ou Thymeleaf, nous écrivons directement dans le flux de sortie de la réponse HTTP, ce qui est idéal pour les API REST.
  • Retour de null : Renvoyer null signale au DispatcherServlet que l'exception a été traitée et qu'aucune autre action n'est requise.

2. Enregistrement du résolveur

Pour que Spring MVC prenne en compte notre résolveur, il doit être enregistré dans le contexte de l'application. Cela se fait via l'interface WebMvcConfigurer.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void configureHandlerExceptionResolvers(List<handlerexceptionresolver> resolvers) {
        // Ajout au début de la liste pour prioriser notre résolveur
        resolvers.add(0, new CustomGlobalExceptionResolver());
    }
}
</handlerexceptionresolver>

Mise en situation et tests

Créons un contrôleur REST simple pour déclencher des erreurs et observer le comportement de notre résolveur.

@RestController
@RequestMapping("/api/math")
public class MathOperationsController {

    @GetMapping("/divide")
    public int performDivision(@RequestParam int numerator, @RequestParam int denominator) {
        return numerator / denominator; // Déclenchera une ArithmeticException si denominator = 0
    }
}

Lorsqu'on appelle /api/math/divide?numerator=10&denominator=0, une erreur 500 est générée. Notre CustomGlobalExceptionResolver intercepte l'ArithmeticException et renvoie la trace de la pile au format JSON.

Le cas particulier des erreurs 404 (NoHandlerFoundException)

Si vous testez une URL inexistante (ex: /api/unknown), vous remarquerez que notre résolveur personnalisé n'est pas appelé. Par défaut, Spring Boot délègue la gestion des 404 à une page d'erreur statique ou au BasicErrorController.

Pourquoi ? Lorsqu'une requête ne correspond à aucun @RequestMapping, le DispatcherServlet tente de la résoudre via les gestionnaires de ressources statiques (ResourceHttpRequestHandler). Si aucune ressource n'est trouvée, il ne lève pas immédiatement de NoHandlerFoundException.

Pour forcer la levée de cette exception et la faire passer par notre résolveur, il faut ajuster la configuration dans le fichier application.properties :

# Lève une exception si aucun handler n'est trouvé
spring.mvc.throw-exception-if-no-handler-found=true

# Désactive le mappage des ressources statiques par défaut
spring.web.resources.add-mappings=false

Analyse du DispatcherServlet

Pour comprendre ce mécanisme, il est utile d'observer le code source du DispatcherServlet, spécifiquement la méthode doDispatch :

// Extrait simplifié de la logique interne de Spring
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // ...
    HandlerExecutionChain mappedHandler = getHandler(processedRequest);
    
    if (mappedHandler == null) {
        // Si aucun handler (y compris statique) n'est trouvé, cette méthode est appelée
        noHandlerFound(processedRequest, response);
        return;
    }
    // ...
}

La méthode getHandler itère sur tous les HandlerMapping enregistrés. Si le mappage des ressources statiques est actif, il retournera un handler même pour une URL inconnue, empêchant ainsi l'appel à noHandlerFound.

Comparaison avec @ControllerAdvice

Bien que l'implémentation de HandlerExceptionResolver soit parfaitement fonctionnelle, elle présente certains inconvénients par rapport à l'approche déclarative moderne :

  • Verbosité : Manipuler directement les objets HttpServletRequest et HttpServletResponse ainsi que les flux d'entrée/sortie rend le code plus lourd et sujet aux erreurs (comme l'oubli de fermer un flux).
  • Manque de flexibilité : @ControllerAdvice permet de cibler des exceptions spécifiques, de définir facilement les codes HTTP via @ResponseStatus, et de s'intégrer nativement avec les convertisseurs de messages HTTP (comme Jackson pour le JSON) sans avoir à sérialiser manuellement les objets.
  • Résolveurs par défaut : Spring fournit déjà des implémentations robustes comme DefaultHandlerExceptionResolver qui traduisent les exceptions Spring en codes HTTP standards.

L'interface HandlerExceptionResolver reste un outil pertinent pour des cas d'usage très spécifiques ou pour des intégrations de bas niveau, tandis que @ControllerAdvice demeure la pratique standard pour la majorité des applications web et API REST.

Étiquettes: Spring Boot Spring MVC HandlerExceptionResolver Java REST API

Publié le 10 juillet à 03h12