Skip to content

Gestión de errores personalizada en REST Controllers con Spring Boot

Una guía práctica sobre la gestión de errores en Spring Boot para REST controllers. Cubre desde escenarios básicos hasta conceptos más avanzados.

Esta guía te muestra cómo implementar gestión de errores personalizada en Spring Boot. Utilizamos no solo las conocidas anotaciones ControllerAdvice y ExceptionHandler, sino también DefaultErrorAttributes y ErrorController para hacer que tus atributos de error personalizados sean uniformes y consistentes.

Introducción

Primero, esta guía cubre los temas básicos: cómo capturar una excepción a nivel de controlador en Spring Boot y mapearla a un código de estado HTTP dado. Haremos esto con las anotaciones @ExceptionHandler y @ControllerAdvice.

Luego, veremos por qué no deberíamos detenernos ahí e ir un paso más allá para tener gestión de errores avanzada y lista para producción en Spring Boot. En el código de ejemplo, mostraremos cómo lograr esto personalizando las respuestas de error con nuestros propios Error Attributes y sobrescribiendo el ErrorController predeterminado.

Repositorio GitHub: Todo el código en este post está disponible en GitHub: Spring Boot REST Exceptions. Si te resulta útil, ¡dale una estrella!

Gestión de errores personalizada en Spring Boot (REST controllers)
Gestión de errores personalizada en Spring Boot (REST controllers)

La aplicación de ejemplo

Usaré como base para este post parte de la aplicación Spring Boot que creé para la Guía para testing de Controllers en Spring Boot: SuperHeroes. Tiene una composición de 2 capas con un Controller y un Repository que recupera entidades SuperHero de un mapa hardcoded en el código. Más que suficiente para lo que queremos demostrar.

Para poder “activar y desactivar” las diferentes configuraciones, incluí tres propiedades en el archivo application.properties de Spring Boot: controlleradvice, attributes y controller. Veremos cómo usarlas más adelante en este post.

La clase SuperHero:

package io.tpd.superheroes.domain;

import javax.validation.constraints.NotBlank;

public final class SuperHero {

    @NotBlank
    private String firstName;
    private String lastName;
    @NotBlank
    private String heroName;

    // Empty constructor is needed for Jackson to recreate the object from JSON
    public SuperHero() {
    }

    public SuperHero(String firstName, String lastName, String heroName) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.heroName = heroName;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public String getHeroName() {
        return heroName;
    }

    // equals and hashcode
}

Estos son la interfaz del repositorio y la implementación en memoria:

package io.tpd.superheroes.repository;

import io.tpd.superheroes.domain.SuperHero;

import java.util.Optional;

/**
 * Provides access to io.tpd.superheroes' data
 * @author moises.macero
 */
public interface SuperHeroRepository {

    /**
     * Retrieves a super hero by the id.
     * If the id does not exist, a {@link io.tpd.superheroes.exceptions.NonExistingHeroException} will be thrown.
     *
     * @param id the unique id of the super hero
     * @return the SuperHero details
     */
    SuperHero getSuperHero(int id);

    /**
     * Retrieves a super hero given his super hero alias.
     *
     * @param heroName the super hero name
     * @return the SuperHero details
     */
    Optional<SuperHero> getSuperHero(String heroName);

    /**
     * Saves the super hero.
     *
     * @param superHero the details of the super hero
     */
    void saveSuperHero(SuperHero superHero);

}
package io.tpd.superheroes.repository;

import io.tpd.superheroes.domain.SuperHero;
import io.tpd.superheroes.exceptions.NonExistingHeroException;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 * Simple, In-memory implementation of SuperHero Repository. It comes with some predefined data.
 *
 * @author moises.macero
 */
@Component
class SuperHeroRepositoryImpl implements SuperHeroRepository {

    private List<SuperHero> superHeroList;

    public SuperHeroRepositoryImpl() {
        superHeroList = new ArrayList<>();
        superHeroList.add(new SuperHero("Jean", "Grey", "Phoenix"));
        superHeroList.add(new SuperHero("Bruce", "Wayne", "Batman"));
        superHeroList.add(new SuperHero("Susan", "Storm", "Invisible woman"));
        superHeroList.add(new SuperHero("Peter", "Parker", "Spiderman"));
    }

    @Override
    public SuperHero getSuperHero(int id) {
        if (id > superHeroList.size()) throw new NonExistingHeroException("Sorry, there are only 4 superheroes...");
        return superHeroList.get(id - 1);
    }

    @Override
    public Optional<SuperHero> getSuperHero(String heroName) {
        return superHeroList.stream().filter(h -> h.getHeroName().equals(heroName)).findAny();
    }

    @Override
    public void saveSuperHero(SuperHero superHero) {
        superHeroList.add(superHero);
    }
}

Como puedes ver en el código, el método para obtener un superhéroe por id puede lanzar una excepción personalizada. La NonExistingHeroException extiende en nuestro caso de RuntimeException, lo que la convierte en una excepción no verificada.

¿Por qué necesitamos gestión de errores común en absoluto?

En Java, las excepciones simplemente ocurren. Si estás construyendo la aplicación común de 3 capas, pueden venir de tu capa de repositorio, lógica de negocio y/o controladores. Puedes mantener las más relevantes para ti bajo control si son de alguna manera esperadas durante tu flujo y puedes recuperarte de ellas. Muchas otras excepciones simplemente se propagarán al nivel del controlador y depende de ti decidir qué hacer con ellas.

Gestionar excepciones en código

Si decides ir por el camino de bajo nivel, puedes encontrarte rodeando todos los métodos que implementan endpoints con bloques try-catch y decidiendo qué respuesta de error y mensaje debería retornarse al inspeccionar el error. Tal vez puedas incluso extraer algo de lógica común, pero aún así, tendrás muchos bloques try-catch repetitivos.

El módulo Spring Web viene con algunas herramientas que puedes usar para evitar todo ese código innecesario. Revisemos en las siguientes secciones los básicos y también algunos consejos avanzados para asegurarte de construir algo que puedas entregar con confianza a producción.

¿Por qué no dejar que las excepciones sean gestionadas por el servidor?

En caso de que no hagas nada con las excepciones, serán gestionadas por el framework o el servidor web que uses, normalmente siguiendo algún comportamiento predeterminado para convertirlas en respuestas HTTP. Estas respuestas son simplemente feas, pueden exponer tu lógica interna y tus consumidores de API no sabrán qué hacer después de obtenerlas. No es una buena idea.

En la práctica: aplicación Spring Boot con gestión de errores predeterminada

Recuerda: todo el código en este post está disponible en GitHub: Spring Boot REST Exceptions. Si te resulta útil, ¡dale una estrella!

Para seguir esta guía, puedes clonar el repositorio desde Github o construir el tuyo desde cero. Solo ten en cuenta que no incluiré todo el código en este post para mantenerlo enfocado en la gestión de errores, aunque enlazaré las clases más relevantes.

Si clonaste el repositorio, asegúrate de que todas las propiedades de configuración estén establecidas en false:

superheroes.errors.controlleradvice = false
superheroes.errors.attributes = false
superheroes.errors.controller = false

Entonces nuestro contexto de aplicación no incluirá ninguna personalización o configuración específica. Es solo una aplicación con un controlador y un repositorio que lanza una excepción. Ahora, puedes usar tu IDE preferido para ejecutar la aplicación o, desde la línea de comandos:

./mvnw spring-boot:run

Hagamos algunas llamadas HTTP y veamos qué pasa. Uso HTTPie ya que está disponible para múltiples plataformas, y también estoy recortando parte de las respuestas que no son relevantes para la explicación. También puedes usar otras herramientas como curl o el Postman con interfaz de usuario.

Caso 1: Obtener un Superhéroe

Llama al endpoint GET con un ID apropiado y obtendrás un superhéroe en formato JSON.

$ http localhost:8080/superheroes/2
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
{
    "firstName": "Bruce",
    "heroName": "Batman",
    "lastName": "Wayne"
}

Caso 2: Forzar la excepción

Si el ID es mayor que el tamaño de la lista, la aplicación lanza una NonExistingHeroException. Sin un ExceptionHandler apropiado, esto termina con el stacktrace en los logs de la aplicación y un código de estado 500 Internal Server Error. Nada agradable, ya que da la impresión de que algo falló del lado del servidor pero en realidad esto debería tratarse como una Bad Request (asumamos que le dijimos a nuestros clientes en la documentación que solo los índices 1-4 están disponibles).

$ http localhost:8080/superheroes/10
HTTP/1.1 500
Content-Type: application/json;charset=UTF-8
{
    "error": "Internal Server Error",
    "message": "Sorry, there are only 4 superheroes...",
    "path": "/superheroes/10",
    "status": 500,
    "timestamp": "2019-09-01T08:45:07.099+0000"
}

Presta también atención al formato del error. Es una estructura JSON con algunos campos genéricos. Esto en realidad proviene de Spring Boot, que inyecta un controlador de errores predeterminado BasicErrorController [javadoc] (inyectado por defecto en el contexto web) que usa un conjunto de DefaultErrorAttributes [javadoc] (inyectados como un bean en el contexto). Spoiler: sobrescribiremos ambos beans más adelante en esta guía, para realizar una gestión de errores completamente personalizada con Spring Boot.

Caso 3: Intentar obtener un superhéroe en HTML

Los Rest Controllers en Spring Boot retornan application/json por defecto pero, ¿qué pasa si intentamos obtener un superhéroe pidiendo contenido HTML?

$ http localhost:8080/superheroes/1 Accept:'text/html'
HTTP/1.1 406
Content-Type: text/html;charset=UTF-8

<html><body><h1>Whitelabel Error Page</h1>
<p>This application has no explicit mapping for /error,
so you are seeing this as a fallback.</p>
<div id='created'>Sun Sep 01 10:58:53 CEST 2019</div>
<div>There was an unexpected error
(type=Not Acceptable, status=406).</div>
<div>Could not find acceptable representation</div>
</body></html>

Esto es extraño. Obtenemos algo llamado Whitelabel Error Page, en HTML, aunque estamos tratando de construir un servicio web que solo devuelve JSON. Parece, al menos, que el código de estado HTTP es consistente: 406 Not Acceptable.

El problema con esta respuesta es que, aunque podrías decir que es culpa del cliente (pidió HTML), el formato de error no es consistente con el formato de error anterior (JSON). No es gran cosa pero, si alguien está probando este servicio desde un navegador web, podemos hacerlo mejor y no proporcionar HTML en absoluto si estamos implementando una REST API que se supone que solo maneja contenido JSON.

Caso 4: Intentar obtener un id inválido usando HTML

Hagamos lo mismo de nuevo pero ahora con un id inválido:

$ http localhost:8080/superheroes/8 Accept:'text/html'
HTTP/1.1 500
Content-Type: text/html;charset=UTF-8

<html><body><h1>Whitelabel Error Page</h1>
<p>This application has no explicit mapping for /error,
 so you are seeing this as a fallback.</p>
<div id='created'>Sun Sep 01 11:12:20 CEST 2019</div>
<div>There was an unexpected error
(type=Internal Server Error, status=500).</div>
<div>Sorry, there are only 4 superheroes...</div>
</body></html>

Esto es aún más extraño. Si no aceptamos HTML, ¿por qué dejaríamos que un error interno se propague? Ahora obtienen un código de estado 500 y este error porque el código encuentra la excepción primero (antes de que Spring intente convertir la respuesta).

Caso 5: El clásico 404

Pidamos un recurso inválido y veamos qué pasa. Primero, aceptando JSON (las peticiones HTTPie por defecto son JSON):

$ http localhost:8080/supers
HTTP/1.1 404
Content-Type: application/json;charset=UTF-8
{
    "error": "Not Found",
    "message": "No message available",
    "path": "/supers",
    "status": 404,
    "timestamp": "2019-09-01T09:18:08.725+0000"
}

Ahora, pidiendo respuesta HTML:

$ http localhost:8080/supers Accept:'text/html'
HTTP/1.1 404
Content-Type: text/html;charset=UTF-8

<html><body><h1>Whitelabel Error Page</h1>
<p>This application has no explicit mapping
for /error, so you are seeing this as a fallback.</p>
<div id='created'>Sun Sep 01 11:19:14 CEST 2019</div>
<div>There was an unexpected error
(type=Not Found, status=404).</div>
<div>No message available</div></body></html>

De nuevo, es al menos debatible si deberíamos responder con una página 404 Not Found o simplemente decirle al cliente que no aceptamos HTML (406), dado que nuestra intención es construir una REST API usando JSON.

Conclusión: ¿por qué necesitas gestión de errores apropiada en Spring Boot?

Si quieres que tu servicio se vea robusto y listo para producción, es mejor que uses algunas de las herramientas disponibles en Spring Boot para personalizar tu comportamiento de gestión de errores en lugar de usar los valores predeterminados básicos.

En este post, nos enfocaremos en algunos requisitos básicos:

  • Gestionar excepciones y traducirlas a códigos de estado HTTP apropiados.
  • Evitar retornar respuestas HTML en Spring Boot si solo estamos aceptando JSON.
  • Unificar formatos de error para que su estructura siempre se vea igual.

Básicos: ControllerAdvice y ExceptionHandler en Spring

Controller Advice

Un Controller Advice es simplemente un tipo de interceptor que rodea la lógica en tus Controllers y te permite aplicarles alguna lógica común. Si conoces Programación Orientada a Aspectos, la palabra Advice te será familiar de todos modos.

Simplemente puedes anotar una clase con @ControllerAdvice para convertirla en la predeterminada para todos tus controladores. Si prefieres tener más de uno, la anotación también te permite especificar a qué paquetes, clases o tipos se aplica.

Desde Spring 4.3, hay una nueva anotación @RestControllerAdvice que es bastante práctica cuando planeas escribir manejadores de excepciones. Es una combinación de @ControllerAdvice con @ResponseBody agregado automáticamente a todos tus métodos anotados con @ExceptionHandler. La anotación @ResponseBody indica que estás enviando un cuerpo de respuesta como resultado de manejar un error, que es lo que típicamente quieres hacer en una REST API si quieres mantener contentos a tus consumidores de API.

Exception Handlers

El Controller Advice puede ser usado para algunas tareas diferentes pero la más popular es capturar excepciones de tu pila de aplicación y traducirlas a respuestas HTTP de manera organizada. Para lograr esto, usas la anotación @ExceptionHandler en un método e indicas qué tipo de Exception quieres manejar. La petición y la instancia de la excepción serán inyectadas mediante argumentos del método si los especificas.

Puedes usar esta anotación junto con @ResponseBody como se indicó antes, si quieres retornar contenido en el cuerpo de la respuesta. No la usaremos en el ejemplo ya que usamos @RestControllerAdvice y por lo tanto vienen implícitamente.

Además, puede que quieras usar @ResponseStatus para establecer el código de estado HTTP que quieres retornar para esa excepción. Sin embargo, dado que estamos planeando crear nuestro propio ResponseEntity con contenido personalizado, incluiremos el estado de respuesta al construir la respuesta.

En la práctica: Controller Advice y Exception Handler

El primer paso para mejorar nuestra aplicación Spring Boot para manejar excepciones es agregar un Exception Handler para, al menos, nuestra excepción personalizada.

Podemos empezar con algo como esto:

@RestControllerAdvice
@ConditionalOnProperty(name = "superheroes.errors.controlleradvice",
                       havingValue = "true")
public class SuperHeroControllerAdvice {

    @ExceptionHandler(NonExistingHeroException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public void handleNonExistingHero() {
    }

}

Si estás ejecutando el ejemplo del repositorio, tienes que establecer superheroes.errors.controlleradvice=true y modificar la versión final del código para que se vea como la versión anterior. Ten en cuenta que estamos agregando la anotación @ConditionalOnProperty para poder activar y desactivar capacidades por el bien de este tutorial. Puedes omitir esa parte si quieres.

Ahora, revisemos de nuevo el Caso 2: Forzar la excepción y veamos cuál es la respuesta.

$ http localhost:8080/superheroes/10
HTTP/1.1 404
Content-Length: 0
Date: Sun, 01 Sep 2019 10:38:13 GMT

¿Genial? Ahora el código de estado de respuesta es 404 Not Found, eso es exactamente lo que queríamos. También nos deshicimos de los errores en los logs (stacktrace) ya que esta excepción ahora está capturada. Sin embargo, ¿qué hay del cuerpo de la respuesta? Parece que hemos sobrescrito el comportamiento predeterminado y, dado que nuestro método no retorna una respuesta, esto se traduce en una respuesta sin cuerpo. No es genial, porque el caso 5 (pidiendo un recurso no existente ej. /supers/3) todavía retorna un cuerpo usando los DefaultErrorAttributes de Spring Boot. Entonces, tenemos una nueva inconsistencia.

Podemos arreglar eso, agregando un resultado ResponseEntity a nuestro método manejador de excepciones. Podrías pensar que incluso es posible instanciar un objeto DefaultErrorAttributes y simplemente imitar lo que Spring Boot está haciendo por defecto. Pero esa no es la intención de esa clase, no puedes simplemente crear una nueva instancia y pasar los valores de atributo que quieres.

Una solución temporal sería crear un bean que tenga exactamente los mismos campos que la clase DefaultErrorAttributes. O podrías simplemente retornar un Map desde el manejador de excepciones. Sin embargo, esto se siente un poco hacky porque estarías adaptando tu lógica a la predeterminada embebida en Spring Boot. También necesitarías conocer parte de los internos para retornar respuestas consistentes.

Entonces, ¿cómo procedemos? Simple: podemos simplemente crear nuestro propio bean ErrorAttributes e inyectarlo en el contexto. Estaremos entonces sobrescribiendo los DefaultErrorAttributes con nuestra implementación personalizada y podemos usar esa estructura de error para todos nuestros errores, en lugar de la predeterminada.

Crear un esquema de error personalizado para Exception Handlers

Ejemplo de error personalizado

La idea es simple, queremos usar nuestro propio formato de error y reemplazar estos campos predeterminados:

{
    "error": "Not Found",
    "message": "No message available",
    "path": "/supers/3",
    "status": 404,
    "timestamp": "2019-09-01T09:18:08.725+0000"
}

Para este ejemplo, intentaremos convertir todos nuestros errores de API en Spring Boot usando la Guía de estilo JSON de Google. Al hacer eso, no estoy recomendando que hagas esto en tus proyectos; es solo un ejemplo. Puedes tener tu propia guía de estilo de errores (más simple que esta) o usar cualquier otra convención disponible en Internet.

En la guía de estilo JSON de Google, este es el formato de los errores de API:

{
  "apiVersion": "2.0",
  "error": {
    "code": 404,
    "message": "File Not Found",
    "errors": [{
      "domain": "Calendar",
      "reason": "ResourceNotFoundException",
      "message": "File Not Found"
    }]
  }
}

La especificación no es muy estricta así que puedes tener tu propia interpretación para campos como code o domain.

Atributos de error personalizados usando la guía de estilo JSON de Google con Spring Boot

Primero, modelamos esta estructura de error en Java. Ten en cuenta que, por el bien del ejemplo, simplifiqué la lógica en esta clase para que solo contenga un error en el array de errores propuesto por las especificaciones de Google. También agregué el campo de las especificaciones sendReport [link] porque es un ejemplo útil de cómo nuestros clientes de API podrían reportar incidentes usando un identificador único que podemos correlacionar en nuestro servicio backend.

package io.tpd.superheroes.controller.errors;

import com.fasterxml.jackson.annotation.JsonIgnore;

import java.util.List;
import java.util.Map;
import java.util.UUID;

public class SuperHeroAppError {

    private final String apiVersion;
    private final ErrorBlock error;

    public SuperHeroAppError(final String apiVersion, final String code, final String message, final String domain,
                             final String reason, final String errorMessage, final String errorReportUri) {
        this.apiVersion = apiVersion;
        this.error = new ErrorBlock(code, message, domain, reason, errorMessage, errorReportUri);
    }

    public static SuperHeroAppError fromDefaultAttributeMap(final String apiVersion,
                                                            final Map<String, Object> defaultErrorAttributes,
                                                            final String sendReportBaseUri) {
        // original attribute values are documented in org.springframework.boot.web.servlet.error.DefaultErrorAttributes
        return new SuperHeroAppError(
                apiVersion,
                ((Integer) defaultErrorAttributes.get("status")).toString(),
                (String) defaultErrorAttributes.getOrDefault("message", "no message available"),
                (String) defaultErrorAttributes.getOrDefault("path", "no domain available"),
                (String) defaultErrorAttributes.getOrDefault("error", "no reason available"),
                (String) defaultErrorAttributes.get("message"),
                sendReportBaseUri
        );
    }

    // utility method to return a map of serialized root attributes,
    // see the last part of the guide for more details
    public Map<String, Object> toAttributeMap() {
        return Map.of(
          "apiVersion", apiVersion,
          "error", error
        );
    }

    public String getApiVersion() {
        return apiVersion;
    }

    public ErrorBlock getError() {
        return error;
    }

    private static final class ErrorBlock {

        @JsonIgnore
        private final UUID uniqueId;
        private final String code;
        private final String message;
        private final List<Error> errors;

        public ErrorBlock(final String code, final String message, final String domain,
                          final String reason, final String errorMessage, final String errorReportUri) {
            this.code = code;
            this.message = message;
            this.uniqueId = UUID.randomUUID();
            this.errors = List.of(
                    new Error(domain, reason, errorMessage, errorReportUri + "?id=" + uniqueId)
            );
        }

        private ErrorBlock(final UUID uniqueId, final String code, final String message, final List<Error> errors) {
            this.uniqueId = uniqueId;
            this.code = code;
            this.message = message;
            this.errors = errors;
        }

        public static ErrorBlock copyWithMessage(final ErrorBlock s, final String message) {
            return new ErrorBlock(s.uniqueId, s.code, message, s.errors);
        }

        public UUID getUniqueId() {
            return uniqueId;
        }

        public String getCode() {
            return code;
        }

        public String getMessage() {
            return message;
        }

        public List<Error> getErrors() {
            return errors;
        }

    }

    private static final class Error {
        private final String domain;
        private final String reason;
        private final String message;
        private final String sendReport;

        public Error(final String domain, final String reason, final String message, final String sendReport) {
            this.domain = domain;
            this.reason = reason;
            this.message = message;
            this.sendReport = sendReport;
        }

        public String getDomain() {
            return domain;
        }

        public String getReason() {
            return reason;
        }

        public String getMessage() {
            return message;
        }

        public String getSendReport() {
            return sendReport;
        }
    }
}

Los nombres de campos elegidos y la estructura de clase siguen el JSON resultante que queremos lograr para que podamos dejar que el serializador JSON haga su trabajo sin incluir más lógica de conversión. Ten en cuenta que hay un método práctico fromAttributeMap() para hacer la conversión desde los campos predeterminados usados por Spring Boot. También podrías construirlos desde cero si no quieres depender de ellos, para eso podrías echar un vistazo a algo de la lógica incluida en DefaultErrorAttributes.

Retornar la respuesta personalizada desde el Exception Handler

Ya hemos definido nuestro formato de error así que podemos empezar a usarlo en el ExceptionHandler sin-contenido-de-respuesta que implementamos en primer lugar. Para hacer eso, simplemente creamos un nuevo objeto de error y lo retornamos con un wrapper ResponseEntity.

@ConditionalOnProperty(name = "superheroes.errors.controlleradvice", havingValue = "true")
@RestControllerAdvice
public class SuperHeroControllerAdvice {

    @Value("${superheroes.sendreport.uri}")
    private String sendReportUri;
    @Value("${superheroes.api.version}")
    private String currentApiVersion;

    @ExceptionHandler(NonExistingHeroException.class)
    public ResponseEntity<SuperHeroAppError> handleNonExistingHero(NonExistingHeroException ex) {
        final SuperHeroAppError error = new SuperHeroAppError(
                currentApiVersion,
                Integer.toString(HttpStatus.NOT_FOUND.value()),
                "This superhero is hiding in the cave",
                "superhero-exceptions",
                "You can't find this superhero right now. Try later.",
                "Saving someone",
                sendReportUri
        );
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

}

Con nuestro nuevo código, ejecutamos de nuevo el Caso 2: Forzar la excepción y veamos cuál es la respuesta.

$ http localhost:8080/superheroes/10
HTTP/1.1 404
Content-Type: application/json;charset=UTF-8

{
    "apiVersion": "1.0",
    "error": {
        "code": "404",
        "errors": [
            {
                "domain": "superhero-exceptions",
                "message": "Saving someone",
                "reason": "You can't find this superhero right now. Try later.",
                "sendReport": "https://dummypage.sendreport.com?id=8c384f5c-8511-4cfd-95f6-71161958abfb"
            }
        ],
        "message": "This superhero is hiding in the cave"
    }
}

Se ve mejor. Lo que obtuvimos ahora no es solo una respuesta 404 Not Found sino también un mensaje de error que sigue la guía de estilo JSON de Google. En la vida real, implementaríamos la página web que soporta la funcionalidad de envío de reportes.

Acabamos de empezar a hacer los errores homogéneos. Ahora, solo obtenemos este error si esa excepción particular es lanzada ya que está en un manejador de excepciones. Queremos evitar crear manejadores de excepciones para cada excepción en Java así que veamos cómo podemos sobrescribir los valores predeterminados en Spring Boot para personalizar el formato de error de REST API retornado.

Personalizar respuestas de error REST en Spring Boot

Sobrescribir DefaultErrorAttributes retornados por controladores

Una vez que hemos definido nuestra estructura de error, tenemos que reemplazar la configuración predeterminada.

Recuerda: todo el código en este post está disponible en GitHub: Spring Boot REST Exceptions. Si te resulta útil, ¡dale una estrella!

Primero, creamos una clase que extiende DefaultErrorAttributes. Como se indicó antes, también puedes ir desde cero y crear tu propia implementación implementando la interfaz ErrorAttributes.

package io.tpd.superheroes.controller.errors;

import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.web.context.request.WebRequest;

import java.util.Map;

class SuperHeroAppErrorAttributes extends DefaultErrorAttributes {
    private final String currentApiVersion;
    private final String sendReportUri;

    public SuperHeroAppErrorAttributes(final String currentApiVersion, final String sendReportUri) {
        this.currentApiVersion = currentApiVersion;
        this.sendReportUri = sendReportUri;
    }

    @Override
    public Map<String, Object> getErrorAttributes(final WebRequest webRequest, final boolean includeStackTrace) {
        final Map<String, Object> defaultErrorAttributes = super.getErrorAttributes(webRequest, false);
        final SuperHeroAppError superHeroAppError = SuperHeroAppError.fromDefaultAttributeMap(
                currentApiVersion, defaultErrorAttributes, sendReportUri
        );
        return superHeroAppError.toAttributeMap();
    }
}

Lo que estamos haciendo aquí es una especie de wrapper que sobrescribe la implementación predeterminada de getErrorAttributes y reemplaza los valores predeterminados por nuestros propios, usando el método mapeador que creamos en SuperHeroAppError. Ten en cuenta que la firma del método en la interfaz ErrorAttributes requiere que retornemos un Map; es por eso que creamos ese método toAttributeMap() en SuperHeroAppError en lugar de serializar el objeto completo en JSON.

Ahora, podemos agregar una clase de Configuration para sobrescribir el bean predeterminado.

@ConditionalOnProperty(name = "superheroes.errors.attributes", havingValue = "true")
@Configuration
public class WebErrorConfiguration {

    @Value("${superheroes.api.version}")
    private String currentApiVersion;
    @Value("${superheroes.sendreport.uri}")
    private String sendReportUri;

    /**
     * We override the default {@link DefaultErrorAttributes}
     *
     * @return A custom implementation of ErrorAttributes
     */
    @Bean
    public ErrorAttributes errorAttributes() {
        return new SuperHeroAppErrorAttributes(currentApiVersion, sendReportUri);
    }

}

De nuevo, usamos aquí @ConditionalOnProperty solo para poder activarlo y desactivarlo; no lo necesitas en tu código. Como ves, lo único que necesitamos es inyectar un bean ErrorAttributes en el contexto para hacer que Spring Boot no inyecte la implementación predeterminada. La versión de API y el URI base de reportes vienen del archivo application.properties.

En la práctica: Ejemplo de sobrescribir ErrorAttributes en Spring Boot

Recuerda: todo el código en este post está disponible en GitHub: Spring Boot REST Exceptions. Si te resulta útil, ¡dale una estrella!

Ahora, tu configuración debería verse así:

superheroes.api.version = 1.0
superheroes.sendreport.uri = https://dummypage.sendreport.com
superheroes.errors.controlleradvice = true
superheroes.errors.attributes = true
superheroes.errors.controller = false

Después de ejecutar la aplicación, podemos probar qué pasa cuando ocurre cualquier error genérico, por ejemplo el Caso 5: un clásico 404 Not Found.

http localhost:8080/supers
HTTP/1.1 404
Content-Type: application/json;charset=UTF-8

{
    "apiVersion": "1.0",
    "error": {
        "code": "404",
        "errors": [
            {
                "domain": "/supers",
                "message": "No message available",
                "reason": "Not Found",
                "sendReport": "https://dummypage.sendreport.com?id=45d3c3d3-32d8-44ed-9a86-3856a8c28141"
            }
        ],
        "message": "No message available"
    }
}

Lo que sucede ahora es que nuestra propia implementación de ErrorAttributes está siendo inyectada en el contexto y usada para construir la respuesta de error. Esta lógica vive en una implementación BasicErrorController [link] que Spring Boot inyecta por defecto en el contexto web cuando usas las dependencias web. Está configurado por defecto con la ruta /error, que es en sí misma la que usa el servidor web para renderizar el error cuando algo sale mal.

La razón por la que estoy explicando la lógica de BasicErrorController es que es la parte que vamos a personalizar a continuación. Aunque ya tenemos una buena implementación base para manejar todo tipo de errores, todavía es posible obtener una página de error Whitelabel si pruebas los casos 4 o 5 descritos arriba (pasando un header Accept: text/html).

Personalizar ErrorController para evitar páginas de error HTML Whitelabel

Basic error controller en Spring Boot

Como se introdujo antes, Spring Boot carga un BasicErrorController predeterminado [link] en el contexto de aplicación web cuando usamos las dependencias web. Este controlador básico maneja errores tanto para tipos de respuesta JSON como HTML. También es, como sugiere su nombre, una implementación básica de AbstractErrorController, y es en esta clase padre donde encontramos el campo y lógica de ErrorAttributes de los que hemos estado aprovechándonos.

Dado que solo queremos deshacernos del manejo de respuesta de error HTML pero queremos mantener los ErrorAttributes, crearé para esta aplicación un nuevo SuperHeroErrorController que extiende AbstractErrorController. También está la opción de implementar la interfaz ErrorController [link] desde cero.

En la práctica

Usaremos una versión aún más simplificada del Basic Controller:

@ConditionalOnProperty(name = "superheroes.errors.controller", havingValue = "true")
@RestController
@RequestMapping({SuperHeroErrorController.ERROR_PATH})
public class SuperHeroErrorController extends AbstractErrorController {

    static final String ERROR_PATH = "/error";

    public SuperHeroErrorController(final ErrorAttributes errorAttributes) {
        super(errorAttributes, Collections.emptyList());
    }

    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        Map<String, Object> body = this.getErrorAttributes(request, false);
        HttpStatus status = this.getStatus(request);
        return new ResponseEntity<>(body, status);
    }

    @Override
    public String getErrorPath() {
        return ERROR_PATH;
    }
}

De nuevo, la anotación @ConditionalOnProperty está ahí solo para poder activar y desactivar esto. No la necesitas.

Cuando habilitamos este controlador, Spring Boot detectará que ya hay uno en el contexto web y no usará la implementación básica. Nuestro controlador de errores no usa ningún view resolver (pasamos una lista vacía) ya que no estamos usando vistas de MVC, solo construyendo una REST API. Simplemente tomamos cualquier error y lo mapeamos a nuestra respuesta personalizada, dado que este controlador obtendrá nuestros SuperHeroErrorAttributes mediante inyección de constructor.

Si clonaste el repositorio, es momento de establecer todas las propiedades como habilitadas:

superheroes.api.version = 1.0
superheroes.sendreport.uri = https://dummypage.sendreport.com
superheroes.errors.controlleradvice = true
superheroes.errors.attributes = true
superheroes.errors.controller = true

Entonces, ejecuta la aplicación y puedes probar algunas peticiones para HTML. Obtendrás una respuesta de contenido vacío esta vez, con el código de estado HTTP correspondiente 406 Not Acceptable.

$ http localhost:8080/superheroes/3 Accept:'text/html'
HTTP/1.1 406
Content-Length: 0

La HttpMediaTypeNotAcceptableException también es manejada por nuestra implementación personalizada del controlador y los atributos de respuesta están establecidos, pero no configuramos ninguna conversión HTML para un SuperHeroAppError así que el cuerpo de respuesta está vacío. Esto es lo suficientemente descriptivo para señalar a cualquier cliente de API HTML distraído con el código de estado, pero también podrías considerar retornar algo más en respuesta si lo necesitas, mediante configurar un @ExceptionHandler dedicado para esa excepción.

Agregar códigos de error personalizados a respuestas de error REST

Para terminar esta guía, cubramos un cambio menor en el código de ejemplo que puede hacer que tus respuestas de error sean aún mejores para tus clientes de API: la introducción de códigos de error específicos.

En nuestro ejemplo, ya tenemos la capacidad de generar un identificador único para cada error que podríamos usar para correlacionar solicitudes de soporte con logs internos (no tenemos ninguno, pero puedes entender la idea).

Sin embargo, lo que también podemos hacer es generar códigos de error de aplicación personalizados para cubrir los tipos de error más comunes. Por ejemplo, podríamos tener un código como NE001 que va junto con nuestras respuestas 404 pero es más específico: solo se retornará si los clientes intentan obtener IDs del recurso superheroes que no existen. Esto ayuda a los clientes de API porque algunos códigos de estado HTTP son demasiado genéricos para determinar exactamente qué salió mal, y puedes usar códigos de aplicación en lugar de los mensajes de descripción -que pueden variar- en tu documentación.

Primero, creamos una interfaz que cualquier clase puede implementar para proporcionar un Error Code genérico. La usaremos para Exceptions.

public interface ErrorCode {

    /**
     * Provides an app-specific error code to help find out exactly what happened.
     * It's a human-friendly identifier for a given exception.
     *
     * @return a short text code identifying the error
     */
    String getErrorCode();
}

El siguiente paso es implementarla en la clase NonExistingHeroException.

public class NonExistingHeroException extends RuntimeException implements ErrorCode {

    public NonExistingHeroException(final String message) {
        super(message);
    }

    @Override
    public String getErrorCode() {
        return "NE-001";
    }
}

Ahora, podemos usar el código de error en nuestros manejadores de excepciones en el controller advice. La guía de estilo que elegimos no es muy estricta sobre el campo code así que no estamos haciendo nada loco aquí. Recuerda que de todos modos estamos usando el código estándar 404 como el estado de respuesta HTTP.

    @ExceptionHandler(NonExistingHeroException.class)
    public ResponseEntity<SuperHeroAppError> handleNonExistingHero(HttpServletRequest request,
                                                                   NonExistingHeroException ex) {
        final SuperHeroAppError error = new SuperHeroAppError(
                currentApiVersion,
                ex.getErrorCode(),
                "This superhero is hiding in the cave",
                "superhero-exceptions",
                "You can't find this superhero right now. Try later.",
                "Saving someone",
                sendReportUri
        );
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

Si ahora intentas acceder a un id no existente, obtendrás este error en la respuesta personalizada. Como se mencionó antes, estos códigos de error específicos de la aplicación son a veces muy útiles para dar más información a tus clientes de API, y son más fáciles de encontrar en la documentación comparados con mensajes de error específicos del lenguaje.

Conclusiones

En esta guía, aprendiste cómo crear respuestas de error personalizadas en REST Controllers de Spring Boot. Cubrimos no solo los ejemplos clásicos de @ControllerAdvice y @ExceptionHandler sino también temas más avanzados como cambiar campos de error en la respuesta usando ErrorAttributes, y sobrescribir el BasicErrorController predeterminado para tener más control sobre el contenido que mostramos. En este caso, usamos este conocimiento para evitar mostrar la página de error white label.

Tener un formato de error consistente y una descripción de error apropiada es una gran ventaja al desarrollar REST APIs. Ayuda a tus consumidores de API a entender qué salió exactamente mal y mejorar la experiencia del usuario mostrando errores descriptivos.

Si estás construyendo una Arquitectura de Microservicios, esta es normalmente una de las preocupaciones que quieres extraer como un artefacto común. Podrías construir tu propia pequeña biblioteca con estas clases y usarla en tus Microservicios. Obtendrás un mecanismo de gestión de errores consistente aunque tengas múltiples aplicaciones Spring Boot.

Espero que encuentres útil esta guía. Como siempre, si quieres aprender más sobre otros temas puedes echar un vistazo a:

Y no olvides comentar y dar una estrella al repositorio GitHub!

Master Microservices with Spring Boot

A hands-on guide to building production-ready microservices

Get the Book

Get More Architecture Insights

Join hundreds of software architects and engineers getting practical insights delivered to their inbox.

Volver al Blog