Spring y Spring Boot: gestión de errores en aplicaciones web y REST.

Última actualización: 15/01/2023
logo spring

Por muy bien que hagamos las cosas, es inevitable que nuestras aplicaciones fallen. Y puede que no sea debido a errores nuestros, sino a un mal uso, intencionado o no, por parte de usuarios y clientes software. Por ejemplo, formularios con datos que violan las restricciones que deben cumplir o solicitudes de direcciones web inexistentes dentro de la aplicación. Estos últimos son fallos controlados, en el sentido de que debemos preverlos e informar al solicitante de la petición qué hizo mal.

Lo cierto es que somos responsables de gestionar de manera adecuada cualquier tipo de error. Por ello, en este artículo exploraremos con detalle las mejores opciones para la gestión de errores en aplicaciones web basadas en Spring MVC. Entre ellas, los prácticos y flexibles gestores de excepciones (la anotación @ExceptionHandler). También veremos algunas capacidades específicas de Spring Boot que nos facilitarán la vida. Contemplaremos tanto las aplicaciones que generan páginas web como las que ofrecen APIs REST.

Nuestras aplicaciones fallarán, sí, pero con elegancia y dignidad 😎

Asumo que posees unos conocimientos básicos acerca de Spring Boot y Spring MVC. Si no es así, puedes aprenderlos en el siguiente tutorial:

Tabla de contenidos

  1. Proyecto de ejemplo
  2. Comportamiento automático de Spring Boot
    1. Whitelabel error page
    2. Páginas de error personalizadas
      1. Estáticas
      2. Dinámicas
    3. API REST
  3. Implementando la url de error en Spring Boot
  4. Los gestores de excepciones de Spring MVC
    1. La anotación @ExceptionHandler
    2. Gestores dentro de un controlador
    3. Gestores transversales a múltiples controladores (@ControllerAdvice)
    4. Múltiples @ControllerAdvice. Problemas de solapamiento y la anotación @Order.
    5. Excepción para 404
    6. Jerarquía de excepciones
    7. Ejemplo real del uso de excepciones para gestionar errores genéricos
  5. Validación en API REST con @Valid y MethodArgumentNotValidException
  6. Resumen final
  7. Código de ejemplo

Proyecto de ejemplo

El proyecto con el que vamos a trabajar es un «cascarón vacío» para una aplicación web en el que añadiremos urls que lanzan excepciones. Partimos de un pom sencillo con Spring Boot 2.7, el starter web y el soporte para JSP, basado en lo que explico en Tips Spring : [BOOT] integración con JSP. No obstante, podrás trasladar sin dificultades lo que veamos a otras tecnologías como Thymeleaf.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.7.2</version>
		<relativePath/>
	</parent>

	<groupId>com.danielme.blog</groupId>
	<artifactId>spring-boot-jsp</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>spring-boot-jsp</name>

	<properties>
		<java.version>11</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>jstl</artifactId>
			<scope>provided</scope>
		</dependency>

		<dependency>
			<groupId>org.apache.tomcat.embed</groupId>
			<artifactId>tomcat-embed-jasper</artifactId>
			<scope>provided</scope>
		</dependency>

	</dependencies>

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

</project>

La url raíz de la aplicación es http://localhost:8080/spring-boot-errors/.

spring.mvc.view.prefix= /WEB-INF/jsp/
spring.mvc.view.suffix= .jsp

server.servlet.context-path=/spring-boot-errors

La clase @SpringBootApplication con el método main. Basta con ejecutarla desde cualquier IDE, como Eclipse o IntelliJ, para que la aplicación se despliegue en el servidor Tomcat embebido que Spring Boot incluye.

@SpringBootApplication
public class ErrorsApp extends SpringBootServletInitializer {

    public static void main(String[] args) {
        SpringApplication.run(ErrorsApp.class, args);
    }

}

Comportamiento automático de Spring Boot

Whitelabel error page

Spring Boot devuelve una página HTML llamada «Whitelabel error page» cuando una excepción «escape» de nuestra aplicación.

Definamos una url que lance una excepción:

@Controller
public class ForceErrorJspController {

    @GetMapping(value = "/throwException")
    public void throwException() {
        throw new IllegalArgumentException("\"I am the error message from JSP Controller\"");
    }

}

Aquí la tenemos (http://localhost:8080/spring-boot-errors/throwException).

Si sientes curiosidad, puedes comprobar que esta página web es una cadena construida en la clase ErrorMvcAutoConfiguration.

Vemos que, en primer lugar, la página explica que se muestra porque no hemos implementado la gestión de la url /error, algo que haremos más adelante. A continuación aparece la traza de la excepción. Esta es la información disponible; algunos datos están desactivados por seguridad:

  • status: entero con el código de estado HTTP.
  • timestamp: objeto Date con el instante en el que se recopilan estos parámetros.
  • exception: la clase de la excepción. Hay que activar este atributo con server.error.include-exception en el application.properties.
  • error: el nombre del error HTTP.
  • message: el mensaje de error, que se corresponde con el mensaje de la excepción lanzada. Activar con server.error.include-message=always.
  • path: la url (relativa) cuya llamada provocó el error.
  • requestId: identificador asociado a la petición HTTP.
  • trace: volcado de la traza completa de la excepción. Activar con server.error.include-stacktrace=always.

Whitelabel page puede desactivarse por completo. Las páginas de error que se mostrarán serán las del servidor en el que se despliegue la aplicación.

server.error.whitelabel.enabled=false
Páginas de error personalizadas
Estáticas

La forma más fácil de usar páginas de error propias consiste en ponerlas en la carpeta /src/main/resources/public/error y nombrarlas con el código de estado de error HTTP que atiende cada una.

Con el formato 4xx.html y 5xx.html abarcaremos todo el rango de códigos de la serie 400 y 500.

Dinámicas

Spring Boot también nos permite definir una página de error dinámica escrita con la tecnología que estemos empleando para generar el HTML, y que en el proyecto de ejemplo es JSP.

El fichero debe llamarse error y ubicarse en la raíz del directorio en el que Spring Boot espera encontrar las páginas web dinámicas. En nuestro caso, sería /src/main/webapp/WEB-INF/jsp/error.jsp. (ver propiedad spring.mvc.view.prefix en el application.properties). Con Thymeleaf, la carpeta predeterminada es /src/main/resources/templates.

Este es el contenido inicial de error.jsp:

<%@ page contentType="text/html; charset=UTF-8"%>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>

<!DOCTYPE HTML>

<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link rel="shortcut icon" type="image/png" href="./favicon.ico">
    <title>Custom Error Page</title>
</head>

<body>
    <h1>Custom error page</h1>

</body>

</html>

Se puede ir más allá y crear una página de error personalizada en función del código de estado HTTP del error, tal y como vimos para HTML estático. Basta con nombrar a la página con el código, por ejemplo 404.jsp, y ubicarla dentro de un subdirectorio llamado error ( /src/main/webapp/WEB-INF/jsp/error/404.jsp):

<%@ page contentType="text/html; charset=UTF-8"%>

<!DOCTYPE HTML>

<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link rel="shortcut icon" type="image/png" href="./favicon.ico">
    <title>Custom Error page - 404</title>
</head>

<body>
    <h1>404 - NOT FOUND</h1>
</body>

</html>

Si forzamos un error de tipo 404 llamando a una url inexistente dentro de nuestra aplicación, veremos esta página. Para los demás errores se seguirá devolviendo la generada por error.jsp.

De momento, el contenido de error.jsp es fijo. Mostremos los datos que aparecen en la Whitelabel page. Resulta sencillo porque Spring Boot los publica en el request:

<ul>
        <li>status: <c:out value="${requestScope.status}" /></li>
        <li>timestamp: <c:out value="${requestScope.timestamp}" /></li>
        <li>error: <c:out value="${requestScope.error}" /></li>
        <li>exception: <c:out value="${requestScope.exception}" /></li>
        <li>message: <c:out value="${requestScope.message}" /></li>
        <li>path: <c:out value="${requestScope.path}" /></li>
        <li>trace: <c:out value="${requestScope.trace}" /></li>
    </ul>

Podemos mostrar datos adicionales si especializamos la clase DefaultErrorAttributes para sobrescribir el método getErrorAttributes. Su cometido es devolver un Map con los parámetros que informan del error y el contexto en el que se produjo.

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {

    //para Spring Boot < 2.3
    /*@Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest,
            boolean includeStackTrace) {
        Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest,
                includeStackTrace);
        errorAttributes.put("jdk", System.getProperty("java.version"));
        return errorAttributes;
    }*/


    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, options);
        errorAttributes.put("jdk", System.getProperty("java.version"));
        return errorAttributes;
    }
    
}

Invocando a super.getErrorAttributes obtenemos el Map original con los parámetros de error que muestra la Whitelabel page. Añadimos los que queramos. En mi caso, uno llamado jdk con la versión de Java.

Aseguremos que Spring utilice CustomErrorAttributes en lugar de DefaultErrorAttributes como implementación del bean errorAttributes requerido por el sistema de gestión de errores. Para ello, en una clase de tipo @Configuration —sirve @SpringBootApplication— creamos un método factoría (@Bean) llamado errorAttributes que devuelva una instancia de CustomErrorAttributes:

@Bean
public ErrorAttributes errorAttributes() {
    return new CustomErrorAttributes();
}

A partir de ahora, cada vez que Spring invoque a errorAttributes#getErrorAttributes será nuestro código el que proporcione el Map con los detalles de un error. En consecuencia, error.jsp podrá mostrar el parámetro jdk porque está en ese Map:

<li>jdk: <c:out value="${requestScope.jdk}" /></li>
API REST

La aplicación de ejemplo, además de páginas HTML, también dispone de una API REST. Tiene un endpoint que lanza una excepción:

@RestController
public class ForceErrorRestController {

    @GetMapping(value = "/api/throwException")
    public void throwException() {
        throw new IllegalArgumentException("\"I am the error message from Rest Controller\"");
    }
    

Spring Boot devuelve un JSON con los parámetros de error. Comprobémoslo utilizando Postman.

Implementando la url de error en Spring Boot

Se puede aumentar el control sobre la gestión de errores implementando un controlador que atienda la url /error a la que Spring Boot redirecciona la petición, sin que salga del servidor, cuando se produce una excepción.

Es posible cambiar la url con el parámetro server.error.path:

server.error.path=/error

Recuerda que veremos la Whitelabel error page cuando esa url no está implementada.

Vamos con un ejemplo que muestra las páginas error.jsp y 404.jsp que ya tenemos en el proyecto. Escribamos un controlador para la url /error (o la de server.error.path). Debe implementar la interfaz ErrorController para informar que la finalidad del controlador es mostrar páginas de error:

@Controller
public class CustomErrorController implements ErrorController {

    private static final Logger logger = LoggerFactory.getLogger(CustomErrorController.class);

    @RequestMapping("/error")
    public String handleError(HttpServletRequest request) {
        logger.info("executing custom error controller");
        
        return is404(request) ? "/error/404" : "error";
    } 

    private boolean is404(HttpServletRequest request)  {
        return HttpStatus.NOT_FOUND
                .value() == (int) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    }

}

handleError recibe el objeto request para comprobar su código de estado y decidir qué vista devolver.

Si queremos que error.jsp continúe mostrando los mismos datos, debemos incorporarlos al Model(*) de Spring MVC. Los obtenemos en nuestro controlador inyectando el bean errorAttributes y llamando a su método getErrorAttributes con el WebRequest asociado a la petición que estamos procesando. Tanto el WebRequest como el Model los recibimos como argumentos de handleError. Recuerda que párrafos atrás creamos ese bean llamado errorAttributes con la claseCustomErrorAttributes.

(*) La clase Model es un contenedor con los datos que el controlador envía a la vista.

@Autowired
private ErrorAttributes errorAttributes;

@RequestMapping("/error")
public String handleError(HttpServletRequest request, WebRequest webRequest, Model model) {
    logger.info("executing custom error controller");

    if (is404(request)) {
        return "/error/404";
    }

   model.addAllAttributes(buildMapErrors(webRequest));

    return "error";
}

private Map<String, Object> buildMapErrors(WebRequest webRequest) {
        ErrorAttributeOptions options = ErrorAttributeOptions.of(
                ErrorAttributeOptions.Include.STACK_TRACE,
                ErrorAttributeOptions.Include.EXCEPTION,
                ErrorAttributeOptions.Include.MESSAGE);
        return errorAttributes.getErrorAttributes(webRequest, options);
}

Presta atención al método buildMapErrors: si quieres que el Map incluya los parámetros que requieren su activación en el application.properties, debes indicarlo en un objeto ErrorAttributeOptions.

Con la creación de CustomErrorController dejamos de utilizar la gestión automática de errores de Spring Boot, y esto presenta un problema: ahora los errores producidos en la API REST no devuelven un JSON, sino el HTML generado por error.jsp. Todos los errores pasan por el método que acabamos de implementar sin importar su origen.

Que un cliente de la API reciba la respuesta que muestra la captura anterior es horrible, aun cuando el código de estado HTTP sea el idóneo. 😕

¿Cuál es la solución? Podemos averiguar si una petición se realizó a la API REST comprobando el parámetro Accept del header porque el solicitante debería haber establecido el valor application/json. Si así fue, podríamos delegar la respuesta en otra url empleando una redirección interna (forward) que devuelva un JSON:

@RequestMapping("/error")
public String handleError(@RequestHeader("Accept") String accept, 
HttpServletRequest request, WebRequest webRequest, Model model) {
    logger.info("executing custom error controller");

    if (isREST(accept)) {
        return "forward:/errorJSON";
    }

     if (is404(request)) {
            return "/error/404";
     }
 
    model.addAllAttributes(buildMapErrors(webRequest));

    return "error";
}

private boolean isREST(String accept) {
    return MediaType.APPLICATION_JSON.toString().equalsIgnoreCase(accept);
}

En CustomErrorController —u otra clase controladora si lo consideramos oportuno— escribimos el método que atiende a /errorJSON:

 @RequestMapping("/errorJSON")
 @ResponseBody
 public CustomErrorJson handleErrorJson(HttpServletRequest request, WebRequest webRequest) {
        Map<String, Object> mapErrors = buildMapErrors(webRequest);
        int status = (int) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        return new CustomErrorJson(status,
                (String) mapErrors.get("error"),
                (String) mapErrors.get("message"),
                (String) mapErrors.get("path"),
                (String) mapErrors.get("trace"),
                (String) mapErrors.get("jdk"));
 }

Puesto que en este caso particular el controlador CustomErrorController no está anotado con @RestController —anotación de conveniencia que incluye @Controller y @ResponseBody— es necesario marcar el método con @ResponseBody. Informa a Spring que handleErrorJson devuelve un objeto con el body de la respuesta HTTP que deberá serializar en un JSON. Ese objeto es una instancia de la clase CustomErrorJson que contiene los mismos valores que muestra error.jsp. Por esta razón, reutilicé el método buildMapErrors.

public class CustomErrorJson {

    private final int status;
    private final String error;
    private final String message;
    private final String path;
    private final String trace;

    public CustomErrorJson(int status, String error, String message, String path, String trace) {
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
        this.trace = trace;
    }

//getters

Este es un ejemplo del JSON devuelto:

{
    "status": 500,
    "error": "Internal Server Error",
    "message": "\"I am the message\"",
    "path": "/spring-boot/api/throwException",
    "trace": "java.lang.IllegalArgumentException: \"I am the message\"...,
     "jdk": "11.0.10"
}

La realidad es que implementar un controlador genérico de errores para Spring Boot no aporta gran cosa. Recomiendo utilizar la gestión de errores estándar de Spring MVC que examinaremos en la próxima sección.

Los gestores de excepciones de Spring MVC

Además de los mecanismos de gestión de errores ya presentados, en Spring Boot siguen disponibles las funcionalidades de Spring MVC. En esta sección indagaremos en ellas con el objetivo de conseguir una gestión de errores exhaustiva y precisa.

La anotación @ExceptionHandler

Los gestores de excepciones son métodos marcados con @ExceptionHandler. Su función es procesar uno o varios tipos de excepciones. Las indicamos con el atributo values de la anotación y las recibimos, si queremos, como un argumento del método. Si no damos valor a values, el método se ejecutará para cualquier excepción compatible con las que reciba como argumento. Otro aspecto a considerar es que los gestores no invalidan la gestión de errores automática de Spring Boot (whitelable page, la url /errors), pero tienen preferencia.

Este método trata excepciones de todo tipo (lo determina su argumento):

@ExceptionHandler
public String handleException(Exception ex) {

Este otro las de tipo IllegalArgumentException:

@ExceptionHandler(value = IllegalArgumentException.class)
public String handleException(Exception ex) {

Y equivale al siguiente:

@ExceptionHandler  
public String handleException(IllegalArgumentException ex) {

¿Qué podemos hacer con ellos? Pues mucho, porque además de la excepción pueden recibir multitud de parámetros relacionados con la petición HTTP que causó el fallo. Son los siguientes: el request; el response; la sesión; el WebRequest; el Local; el contenido del request como InputStream \ Reader; el contenido que tendrá el response como OutputStream \ Writer; el Model de Spring MVC.

En cuanto a la respuesta, también contamos con variadas opciones que incluyen a los «sospechosos habituales» usados en los métodos de los controladores. Son, entre otros, el String con el nombre de la vista, ResponseEntity y cualquier objeto a serializar en un JSON.

¿Dónde se declaran? Lo vemos en los próximos apartados en los que implementaremos gestores de excepciones en el proyecto de ejemplo.

Gestores dentro de un controlador

Los gestores pertenecientes a un controlador solo atienden a las excepciones lanzadas por los métodos del controlador.

Añadamos un gestor a ForceErrorRestController:

@RestController
public class ForceErrorRestController {

    @GetMapping(value = "/api/throwException")
    public void throwException() {
        throw new IllegalArgumentException("\"I am the error message from Rest Controller\"");
    }

    @ExceptionHandler(value = IllegalArgumentException.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public Map<String, Object> handleException(Exception ex) {
        Map<String, Object> map = new HashMap<>();
        map.put("message", ex.getMessage());
        map.put("timestamp", new Date());
        return map;
    }

}

Ahora, la excepción IllegalArgumentException lanzada al llamar a /api/throwException causa la invocación de handleException, quien se encargará de enviar la respuesta al cliente. El uso de Map permite crear al vuelo un JSON. Lo he puesto con un fin didáctico, pues todas las respuestas JSON deberían modelarse con una clase.

El código de estado predeterminado de la respuesta HTTP de un gestor de excepciones es 200 (OK). Como este valor es inapropiado, he especificado el código 500 con la anotación @ResponseStatus. También se podría devolver un objeto ResponseEntity que contenga, además de la respuesta, el código.

Este es un ejemplo de la respuesta:

{
    "message": "\"I am the error message from Rest Controller\"",
    "timestamp": "2022-07-28T19:01:49.861+00:00"
}
Gestores transversales a múltiples controladores (@ControllerAdvice)

Las clases marcadas con @ControllerAdvice aplican sus métodos de tipo @ExceptionHandler a un conjunto de controladores que cumplan con lo definido en una de las siguientes propiedades de la anotación:

  • basePackages. Controladores situados en algunos de los paquetes indicados, definidos por una cadena.
  • basePackagesClasses. Controladores situados en algunos de los paquetes indicados, definidos por los Class de cualquier clase del paquete.
  • assignableTypes. Controladores asignables a cualquiera de las clases indicadas por su Class.
  • annotations. Controladores con alguna de las anotaciones indicadas por su Class.

Si no se especifica el criterio de selección, el ControllerAdvice se aplicará a todas las clases del sistema.

Repliquemos la gestión de errores que hicimos en CustomErrorController usando @ControllerAdvice. Debemos tener en cuenta estas limitaciones:

  • No podemos usar ErrorAttributes.
  • Para el error 404, Spring no lanza excepción alguna. Lo solucionaremos más adelante.

Empecemos con las excepciones lanzadas por la API REST:

@ControllerAdvice(annotations = RestController.class)
public class ErrorRestControllerAdvice {

    private static final Logger logger = LoggerFactory.getLogger(ErrorRestControllerAdvice.class);

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public CustomErrorJson handleException(Exception ex, HttpServletRequest request) {
        logger.info("executing exception handler (REST)");

        return new CustomErrorJson(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                ex.getClass().getName(),
                ex.getMessage(),
                request.getRequestURI(),
                ExceptionUtils.buildTrace(ex));
    }

}

De nuevo aparece la anotación @ResponseBody. En este ejemplo podemos ahorrárnosla usando @RestControllerAdvice en vez de @ControllerAdvice. Es una anotación de conveniencia que contiene @ControllerAdvice y @ResponseBody:

@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {

Otro detalle interesante es la prioridad de los gestores aplicables a un mismo tipo de excepción: los definidos dentro de un controlador tienen preferencia sobre los declarados en las clases @ControllerAdvice. A causa de este comportamiento, si queremos que la excepción lanzada en /api/throwException sea tratada por ErrorRestControllerAdvice, debemos eliminar el gestor que escribimos antes en ForceErrorRestController.

Múltiples @ControllerAdvice. Problemas de solapamiento y la anotación @Order.

Escribamos el @ControllerAdvice para los controladores no REST. Enseguida explico por qué este ejemplo abre una nueva sección; primero, el código:

@ControllerAdvice(annotations=Controller.class)
public class ErrorJspControllerAdvice {
 
    private static final Logger logger = LoggerFactory.getLogger(ErrorJspControllerAdvice.class);
 
    @ExceptionHandler
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleException(Exception ex, HttpServletRequest request, Model model) {
        logger.info("executing exception handler (web)");
 
        model.addAttribute("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
        model.addAttribute("error", ex.getClass().getName());
        model.addAttribute("message", ex.getMessage());
        model.addAttribute("path", request.getRequestURI());
        model.addAttribute("jdk", System.getProperty("java.version"));
        model.addAttribute("trace", ExceptionUtils.buildTrace(ex));

        return "error";
    }
    
}

Si bien todo parece correcto, ErrorJspControllerAdvice y ErrorRestControllerAdvice no se comportarán de la manera esperada: ahora las excepciones lanzadas por los controladores @RestController son tratadas por ErrorJspControllerAdvice. Parece que ErrorRestControllerAdvice se ha roto 😢.

Antes de explicar este funcionamiento tan desconcertante es necesario tener claro dos puntos:

  • @RestController incluye a @Controller, así que ErrorJspControllerAdvice es aplicable a todos los controladores.
  • Una excepción solo es tratada por un gestor.

Sin entrar en demasiados detalles, durante la creación del contexto Spring revisa todas las clases marcadas con @ControllerAdvice. La clave está en que construirá una estructura de datos ordenada —luego veremos cuál— en la que buscará, partiendo del primer elemento, el gestor de una excepción cuando se produzca.

Nuestro problema es que el gestor declarado en ErrorJspControllerAdvice aparece en esa estructura de datos antes que el de ErrorRestControllerAdvice. Y puesto que ErrorJspControllerAdvice es aplicable a las clases @RestController, la consecuencia es que ErrorJspControllerAdvice#handleException será el gestor que Spring ejecute siempre para las excepciones Exception lanzadas desde @RestController : es el primero idóneo que encontrará en la estructura de datos —y además, reitero, solo puede ejecutar uno—.

Esta imagen ilustra lo anterior. Para las Exception lanzadas en @Controller y @RestController siempre se usa el primer bloque azul; el segundo resulta inútil.

Si quieres hurgar en las entrañas de Spring y ver cómo se hace todo esto, las clases a estudiar son ExceptionHandlerExceptionResolver y ExceptionHandlerMethodResolver. La primera contiene la estructura de datos a la que me he estado refiriendo. Si bien es un Map, la implementación LinkedHashMap garantiza un orden en la iteración:

private final Map<ControllerAdviceBean, ExceptionHandlerMethodResolver> exceptionHandlerAdviceCache =
			new LinkedHashMap<>();

Volvamos al proyecto de ejemplo. Hay dos configuraciones que nos permitirán conseguir que ErrorRestControllerAdvice y ErrorRestControllerAdvice sean compatibles y hagan lo esperado:

  • Cambiar el orden en el que los gestores se registran en Spring.

ErrorRestControllerAdvice#handleException debe registrarse antes que ErrorJspControllerAdvice#handleException en el LinkedHashMap. Demos prioridad al @ControllerAdvice más específico para que no sea ocultado por el más genérico. Tendremos lo siguiente.

Ahora, cuando Spring busque el gestor para Exception y @RestController tomará el primer bloque. Pero en el caso de @Controller, descartará ese bloque y continuará la búsqueda de un gestor adecuado hasta llegar al segundo bloque.

El orden del registro se establece con un número en la anotación @Order. Cuanto menor sea el valor, mayor la prioridad:

@Order(1)
@RestControllerAdvice(annotations = RestController.class)
public class ErrorRestControllerAdvice {
@Order(2)
@ControllerAdvice(annotations= Controller.class)
public class ErrorJspControllerAdvice 

Esta solución tan elegante presenta un inconveniente: estamos alterando la organización automática que Spring hace de los gestores. Debido a ello, es probable que debamos configurar manualmente el orden de todos los @ControlerAdvice que implementemos. Tendremos este problema en la última sección del artículo.

@Order también se usa con el mismo propósito en otras funcionalidades de Spring, como el establecimiento del orden de ejecución de los listeners de eventos.

  • Cambiar las clases a las que se aplican los @ControllerAdvice aprovechando la flexibilidad de la anotación.

Por ejemplo, lo que haré será configurar ErrorJspControllerAdvice para que solo se aplique a los controladores del paquete de la clase ForceErrorJspController (com.danielme.springboot.controllers.web). Estoy asumiendo que todos los @Controller del proyecto estarán en ese paquete.

@ControllerAdvice(basePackageClasses = ForceErrorJspController.class)
//@ControllerAdvice(annotations=Controller.class)
public class ErrorJspControllerAdvice {

La problemática que acabamos de analizar tiene una moraleja: cuidado con los solapamientos indeseados entre clases @ControllerAdvice.

Excepción para 404

En caso de recurso no encontrado (404), Spring no lanza una excepción que pueda ser capturada por un gestor. Sin embargo, con Postman veremos lo siguiente.

La clase CustomErrorController que atiende /error está procesando el error 404 y hará lo mismo para cualquier excepción que no trate un gestor. Es la última barrera entre las excepciones y los clientes de la aplicación.

Se puede pedir a Spring que lance una excepción NoHandlerFoundException cuando desconozca cómo tratar una petición, situación que Spring interpreta como un error 404. Esta es la configuración:

spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false

El tratamiento de NotHandlerFoundException requiere de un @ControllerAdvice global, esto es, aplicable a todas las clases. ¿El motivo? La excepción no la lanza uno de nuestros controladores, sino DispatcherServlet. Si desconoces esta importantísima clase de Spring MVC, debes saber que es responsable de atender las peticiones HTTP entrantes y decidir qué hacer con ellas, como invocar a un método de un controlador y procesar su retorno para devolver la respuesta de la petición.

Este es el @ControllerAdvice global:

@ControllerAdvice
public class GlobalControllerAdvice {

    private static final Logger logger = LoggerFactory.getLogger(GlobalControllerAdvice.class);

    @ExceptionHandler(NoHandlerFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleException(HttpServletRequest request) {
        logger.info("processing NoHandlerFoundException");
        return "/error/404";
    }
    
}

Estamos devolviendo el HTML que genera 404.jsp. Pero dado que nuestro ejemplo también cuenta con una API REST, tendremos que repetir la estrategia de CustomErrorController si queremos devolver un JSON cuando sea pertinente.

Otro inconveniente: la opción spring.web.resources.add-mappings deshabilita la configuración automática que Spring Boot hace de los recursos estáticos ubicados en /spring-boot-errors/src/main/resources/static/.

Demasiadas molestias. Mejor dejar que actúe el sistema automático de control de errores de Spring Boot.

Jerarquía de excepciones

Perdóname la obviedad: IllegalArgumentException es también Exception. Cuando se lance IllegalArgumentException, ¿qué sucederá si tenemos algo así en un controlador o en una clase @ControllerAdvice?:

@ExceptionHandler(value = IllegalArgumentException.class)
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, Object> handleExceptionIllegal(Exception ex) {
    ...
}

@ExceptionHandler
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, Object> handleException(Exception ex) {
  ...
}

Spring es inteligente y ejecuta el gestor más específico. Esto significa que la excepción IllegalArgumentException se procesará en handleExceptionIllegal y que handleException, más genérico, se ignora. Aun así, hay que tener presente que cuando trabajamos con múltiples @ControllerAdvice los gestores muy genéricos como los que hemos escrito para Exception resultan peligrosos: pueden «comerse» a los más específicos, dependiendo del orden en el que se registren los @ControllerAdvice.

¿Y si Spring no tiene más remedio que ejecutar más de un gestor? Imposible: ya sabemos que únicamente puede ejecutar un gestor para una excepción. Por tanto, la siguiente configuración es incorrecta:

@ExceptionHandler
 public void handle1(IllegalArgumentException ex) {

}

@ExceptionHandler
public void handle2(IllegalArgumentException ex) {

}

Y el arranque de Spring se abortará por este error:

Caused by: java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [class java.lang.IllegalArgumentException]: 
{public void com.danielme.springboot.controllers.errors.advices.ErrorRestControllerAdvice.handle1(java.lang.IllegalArgumentException), 
public void com.danielme.springboot.controllers.errors.advices.ErrorRestControllerAdvice.handle2(java.lang.IllegalArgumentException)

Menos mal que el mensaje circunstancia el problema haciéndolo fácil de solventar. Ten en cuenta que los gestores culpables del error pueden estar repartidos en varios @ControllerAdvice.

Ejemplo real del uso de excepciones para gestionar errores genéricos

En el tutorial introductorio al desarrollo de APIs REST con Spring Boot creé este servicio:

@PutMapping(value = "/{id}")
public ResponseEntity<Void> update(@PathVariable Long id,
                                       @RequestBody @Valid CountryRequest countryRequest) {
    boolean wasUpdated = countryService.update(id, countryRequest);
    return wasUpdated ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}

Su cometido es actualizar en la base de datos el contenido de una entidad de JPA llamada Country. Indicamos el país a actualizar proporcionando su identificador en la url. Si el país no existe, se retorna el código 404. ¿Cómo tomamos esta decisión? El método countryService#update (línea 4, contiene la lógica de actualización) devuelve falso si la entidad no puede modificarse porque no existe.

Gracias a lo que hemos visto en este artículo podemos aplicar una solución más elegante y práctica basada en excepciones:

@Transactional
public void update(Long id, CountryRequest countryRequest) {
    Country country = countryRepository
            .findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(Country.class, id));
    country.setName(countryRequest.getName());
    country.setPopulation(countryRequest.getPopulation());
}

Cuando el país no se encuentre (findById lo obtiene en un Optional), lanzamos una excepción creada por nosotros. Será de tipo runtime para que estemos exentos de capturarla o declararla en la signatura del método, como sucede con las checked exception —un invento maligno—. Aquí tienes su código:

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(Class clazz, Long id) {
        super("Resource " + clazz.getSimpleName() + " with id " + id + " not found ");
    }

}

La excepción «ascenderá» hasta ser atrapada por un gestor como el que sigue:

@ControllerAdvice
public class ExceptionControllerAdvice {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionControllerAdvice.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public void handleException(ResourceNotFoundException ex) {
        logger.warn("Not found", ex);
    }

}

Ahora, cada vez que nos enfrentemos a esta casuística (devolver 404 porque un recurso no exista), lanzaremos EntityNotFoundException. ExceptionControllerAdvice se encargará del resto.

Una alternativa «mágica» consiste en olvidarnos de los gestores de excepciones y simplemente marcar con @ResponseStatus la excepción:

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

La respuesta tendrá el status indicado e incluirá un JSON con los detalles de la excepción.

Validación en API REST con @Valid y MethodArgumentNotValidException

Vamos a crear un servicio REST que reciba mediante POST un JSON con un objeto de tipo Country. Sus atributos tienen restricciones definidas con las anotaciones de Hibernate Validator. Cuando los validadores genéricos proporcionados por esta librería sean insuficientes, puedes crear validadores personalizados.

public class Country {
    
    @NotNull
    private String name;
    
    @NotNull
    @Min(1)
    @Max(2000000000)
    private Integer population;
@RestController
@RequestMapping(CountryRestController.COUNTRY_RESOURCE)
public class CountryRestController {

    public static final String COUNTRY_RESOURCE = "/api/country";

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void add(@RequestBody @Valid Country country) {
       //nothing here
    }

}

Conseguir que Spring compruebe de forma automática que los datos recibidos por el endpoint cumplen las restricciones definidas en la clase Country es pan comido: solo hay agregar a la declaración del argumento la anotación @Valid. Si hay algún error, se lanza la excepción MethodArgumentNotValidException. La respuesta del servicio será este JSON, siempre y cuando no tratemos los errores en un controlador de tipo @ControllerAdvice o ErrorController:

{
    "timestamp": "2019-03-22T18:25:07.447+0000",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
            "codes": [
                "Min.country.population",
                "Min.population",
                "Min.java.lang.Integer",
                "Min"
            ],
            "arguments": [
                {
                    "codes": [
                        "country.population",
                        "population"
                    ],
                    "arguments": null,
                    "defaultMessage": "population",
                    "code": "population"
                },
                1
            ],
            "defaultMessage": "must be greater than or equal to 1",
            "objectName": "country",
            "field": "population",
            "rejectedValue": -234,
            "bindingFailure": false,
            "code": "Min"
        },

Demasiada información ¡y solo es una parte del mensaje! Mejor personalizar la respuesta y devolver un mensaje más simple y claro. La técnica ya la conocemos: crear un @ExceptionHandler para MethodArgumentNotValidException. Por ejemplo:

@ControllerAdvice(annotations = RestController.class)
public class ConstraintViolationRestControllerAdvice {

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public List<ValidationError> handleException(MethodArgumentNotValidException ex) {
        return ex.getBindingResult()
                .getAllErrors()
                .stream()
                .map(this::mapError)
                .collect(Collectors.toList());
    }

    private ValidationError mapError(ObjectError objectError) {
        if (objectError instanceof FieldError) {
            return new ValidationError(((FieldError) objectError).getField(),
                    objectError.getDefaultMessage());
        }
        return new ValidationError(objectError.getObjectName(), objectError.getDefaultMessage());
    }

}

Al añadir al proyecto este nuevo @ControllerAdvice, es posible que las anotaciones @Order que pusimos antes se vuelvan en nuestra contra. Si ErrorRestControllerAdvice tiene mayor prioridad que ConstraintViolationRestControllerAdvice, su gestor será el que procese MethodArgumentNotValidException porque atiende a Exception. ¿La solución? Reajustar el orden para dar más prioridad a ConstraintViolationRestControllerAdvice:

@Order(0)
@ControllerAdvice(annotations = RestController.class)
public class ConstraintViolationRestControllerAdvice {

En cuanto al método handleException, la excepción contiene el detalle de cada error de validación en objetos de la clase ObjectError. Los transformamos en objetos de tipo ValidationError que solo informen del atributo erróneo y el motivo del fallo:

package com.danielme.springboot.model;

public class ValidationError {

    private final String field;
    private final String message;

    public ValidationError(String field, String message) {
        super();
        this.field = field;
        this.message = message;
    }

    public String getField() {
        return field;
    }

    public String getMessage() {
        return message;
    }

}
[
    {
        "field": "name",
        "message": "must not be null"
    },
    {
        "field": "population",
        "message": "must be greater than or equal to 1"
    }
]

Gracias a esta información, el solicitante de la actualización sabrá con claridad qué está haciendo mal. Igual ese solicitante fuimos nosotros 😉

Resumen final

¡Fue un artículo extenso! Espero que te sea de ayuda. Lo resumo en varios titulares para que tengas clara la visión general de la gestión de errores en aplicaciones web desarrolladas con Spring.

Spring Boot:

  • Muestra una pantalla de error, llamada Whitelabel error page, con la traza de las excepciones que escapen del sistema. En el caso de APIs REST, devuelve la misma información en un JSON.
  • Whitelabel error page puede sustituirse con páginas web estáticas o dinámicas (JSP, Thymeleaf…).
  • Adquirimos mayor control implementando en un controlador la url a la que Spring Boot redirecciona una petición HTTP en caso de excepción.

Además de lo anterior, contamos con las funcionalidades de tratamientos de errores de Spring MVC:

  • @ExceptionHandler marca métodos —gestores de excepciones— para que atiendan ciertas excepciones. Los gestores pueden estar en controladores o bien en clases marcadas con @ControllerAdvice que aplican sus gestores a las clases que queramos. Asimismo, tienen preferencia sobre los mecanismos de gestión de errores propios de Spring Boot. Y a lo sumo, solo se ejecuta un gestor para una excepción.
  • Cuidado con los posibles solapamientos entre gestores y las configuraciones complejas. Podemos complicarnos la vida y tener que establecer el orden de las clases @ControllerAdvice con @Order.
  • Los gestores permiten modelar comportamientos genéricos con excepciones.
  • Cuando usamos @Valid en APIs REST, es posible construir la respuesta con los errores de validación mediante el tratamiento de MethodArgumentNotValidException en un gestor.

Código de ejemplo

El proyecto completo se encuentra en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.

Otros tutoriales relacionados con Spring

Testing en Spring Boot con JUnit 4\5. Mockito, MockMvc, REST Assured, bases de datos embebidas

Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA

Spring JDBC Template: simplificando el uso de SQL

Persistencia en BD con Spring Data JPA

Spring REST: Securización BASIC y JDBC con Spring Security

Persistencia en BD con Spring: Integrando JPA, c3p0, Hibernate y EHCache

Testing Spring con JUnit 4

Ficheros .properties en Spring IoC

Master Pyhton, Java, Scala or Ruby

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.