Manejo de excepciones en Spring Boot

Motivación

Se requiere un mecanismo global o centralizado basado en las excepciones, para poder manejar y traducir en errores entendibles hacia las integraciones (frontend y/o otros clientes).

Introducción

La gestión de excepciones en el sentido amplio de la palabra, se refiere a la captura de eventos de tipo errores que se producen en las aplicaciones durante su ejecución. Estos errores pueden ser de muchos tipos, desde fallas de red, errores en accesos a una base de datos, errores en tiempo de ejecución (runtime) o simplemente por validaciones de negocio.

El manejo de excepciones tiene como objetivo hacer más seguras y robustas las aplicaciones así como también tener un mecanismo para dar una respuesta elegante a los usuarios mejorando la confiabilidad y resistencia a fallas en las aplicaciones.

Usos

  • Manejo global de excepciones:

    • ¿ Cuándo usarlo ?
      • Cuando se necesita un formato de respuesta de error consistente para toda la aplicación.
      • Para evitar exponer trazas de la aplicación con información relevante, de esta forma evitar posibles futuros atacantes.
  • Manejo de excepciones en el controlador:

    • Utilizar cuando se necesita una solución particular (distinta a la ofrecida por el manejo de errores globales), como por ejemplo, integraciones donde necesitamos adaptar nuestra respuesta.

Implementación

Para su implementación, Spring Boot nos provee algunas herramientas, las más comunes para este tipo de manejadores son @ControllerAdvice y de @RestControllerAdvice, el primero para cualquier tipo de controlador anotado con @Controller (MCV de Spring) y el segundo esta especializado en controladores de tipo Rest (aplica sólo a los controladores anotados como @RestController)

Para que estos componentes puedan funcionar correctamente y como su nombre lo indica, deben poder atrapar excepciones del tipo específico (jerarquía) que necesitemos manejar y hacer la interpretación de la excepciín, como por ejemplo: se requiere atrapar cualquier Exception del tipo IllegalArgumentException y convertirla en un bad request (error 400 de http).

Hablemos un poco de excepciones

Los manejadores de excepciones siempre actúan cuando una excepción es lanzada y no ha sido atrapada por la capa de controladores, éstas pueden ser lanzadas desde las capas de acceso a datos, directamente desde la capa de negocio o simplemente desde la capa de controladores (validación de entrada).

Aquí la discusión se vuelve un poco densa y depende mucho el cómo quieres trabajar tu jerarquía de excepciones, puede ser tan compleja como quieras (casos en que necesitas mucho detalle en la respuesta del manejador) o tan simple como mantener un par de excepciones que manejar e interpretar.

En un articulo anterior escribí acerca de tipos de excepciones, jerarquía y características el manejo de excepciones. Les dejo aquí el enlace a este artículo Checked y Unchecked Exception.

Una de las alternativas mas simples es utilizar Excepciones de tipo RunTimeException ya que tienen la característica de no ser atrapadas de forma imperativa o declarativa, de esta forma excepciones que son lanzadas en las capas inferiores saltan hasta el manejador de Excepciones, claro está, solo si son manejadas en dicho manejador.

Veamos un ejemplo

En SpringBoot tenemos un par de mecanismos para poder manejar excepciones y que nos permite evitar exponer el stack trace directo al usuario (nos da seguridad en caso de exponer algún dato sensible) y en su reemplazo nos permite modelar una salida elegante dependiendo de la excepción lanzada.

Para efectos prácticos en este artículo utilizaremos sólo excepciones de tipo Runtime ya que este tipo de excepciones suben por el stack sin ser capturadas por nuestro código hasta llegar al lugar donde queremos hacer el manejo de la excepción.

Rest Controller Advice

GlobalExceptionHandleres una clase que se anota con @RestControllerAdvice y es donde podremos hacer el manejode la excepción. Veamos un ejemplo a continuación:

En este primer ejemplo, se evidencia que se atrapan las exepciones especificas y mas altas de la jerarquía, NotFoundException, BadRequestException y UnauthorizedException, ademas para cada una de ellas hay un método disponible donde podremos por ejemplo hacer un log del error para tener el detalle de lo ocurrido y finalmente enviar hacia el consumnidor, el correspondiente codigo Http para cada caso. Nota: en este ejemplo el body de la respuesta es vacío.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package cl.pcollaog.eh.handler;

import cl.pcollaog.eh.exception.BadRequestException;
import cl.pcollaog.eh.exception.NotFoundException;
import cl.pcollaog.eh.exception.UnauthorizedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);

@ExceptionHandler(BadRequestException.class)
public ResponseEntity<Void> badRequestHandler(BadRequestException bre) {
LOGGER.error("badRequestHandler - message {}", bre.getMessage());
return ResponseEntity.badRequest().build();
}

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Void> notFoundExceptionHandler(NotFoundException nfe) {
LOGGER.error("notFoundExceptionHandler - message: {}", nfe.getMessage());
return ResponseEntity.notFound().build();
}

@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<Void> unauthorizedExceptionHandler(UnauthorizedException ue) {
LOGGER.error("unauthorizedExceptionHandler - message: {}", ue.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}

En este segundo ejemplo, vamos a manipular la respuesta (vamos a suponer que la integración lo pide así) para cada caso.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package cl.pcollaog.eh.handler;

import cl.pcollaog.eh.exception.BadRequestException;
import cl.pcollaog.eh.exception.NotFoundException;
import cl.pcollaog.eh.exception.UnauthorizedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;

@RestControllerAdvice
public class GlobalExceptionHandler {

private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);

@ExceptionHandler(BadRequestException.class)
public ResponseEntity<CustomResponse> badRequestHandler(BadRequestException bre) {
LOGGER.error("badRequestHandler - message {}", bre.getMessage());
CustomResponse cr = new CustomResponse((HttpStatus.BAD_REQUEST), bre.getMessage());
return new ResponseEntity<>(cr, cr.getStatus());
}

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<CustomResponse> notFoundExceptionHandler(NotFoundException nfe) {
LOGGER.error("notFoundExceptionHandler - message: {}", nfe.getMessage());
CustomResponse cr = new CustomResponse((HttpStatus.NOT_FOUND), nfe.getMessage());
return new ResponseEntity<>(cr, cr.getStatus());
}

@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<CustomResponse> unauthorizedExceptionHandler(UnauthorizedException ue) {
LOGGER.error("unauthorizedExceptionHandler - message: {}", ue.getMessage());
CustomResponse cr = new CustomResponse((HttpStatus.UNAUTHORIZED), ue.getMessage());
return new ResponseEntity<>(cr, cr.getStatus());
}

public static class CustomResponse {

private final LocalDateTime date;

private final HttpStatus status;

private final String message;

public CustomResponse(HttpStatus status, String message) {
this.date = LocalDateTime.now();
this.status = status;
this.message = message;
}
// ... Omitiré los getter
}
}

Para el caso de NotFoundException la respuesta será la siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 404 
Connection: keep-alive
Content-Type: application/json
Date: Sat, 16 Mar 2024 22:20:02 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
"date": "2024-03-16T19:20:02.462356",
"message": "Entity not found",
"status": "NOT_FOUND"
}

Para el caso de BadRequestException la respuesta será la siguiente:

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 400 
Connection: close
Content-Type: application/json
Date: Sat, 16 Mar 2024 22:16:41 GMT
Transfer-Encoding: chunked

{
"date": "2024-03-16T19:16:41.886069",
"message": "Invalid argument",
"status": "BAD_REQUEST"
}

Finalmente para el caso de UnauthorizedException la respuesta será la siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 401 
Connection: keep-alive
Content-Type: application/json
Date: Sat, 16 Mar 2024 22:21:38 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
"date": "2024-03-16T19:21:38.011316",
"message": "Invalid Token",
"status": "UNAUTHORIZED"
}

Como podrán ver, en el handler (método) podrán manejar la excepción a conveniencia y podrán enviar el mensaje y codigo de error que necesiten hacia el consumidor del servicio.

Para finalizar les dejo un diagrama del funcionamiento del GlobalExceptionHandler con el ejemplo de 404 Not Found.

Si tienen alguna observación no duden en comentar.

Checked y Unchecked Exception

Dado que varios de mis lectores me han solicitado un post sobre tipos de excepciones en Java, les dejaré un par de notas para que consideren al momento de diseñar soluciones y por su puesto el cómo manejar los errores. Aquí vamos!

Primero que todo, un par de definiciones básicas y características antes de partir.

Jerarquía de Excepciones

Esta es la jerarquía de excepciones de mas alto nivel que encontramos en Java.

Unchecked Exception

Generalmente este tipo de excepciones son lanzadas por la aplicación y se generan a partir de errores en tiempo de Runtime. Este tipo de excepciones representan errores en el código y que la aplicación no es capaz de controlar. Algunos de errores causados y que lanzan este tipo de excepciones, por ejemplo, argumentos inválidos pasados a un método (argumentos null pueden causar NullPointerException), otro error común son la excepciones del tipo IndexOutOfBoundsException y que son lanzadas cuando se quieren obtener elementos de una lista y el índice que se entrega está fuera del tamaño del arreglo. Como podrán ver, son errores de programación y que generarán defectos en momento de correr la aplicación (no así al compilar).

Unchecked runtime exceptions represent conditions that, generally speaking, reflect errors in your program’s logic and cannot be reasonably recovered from at runtime.

Gosling, Arnold and Holmes, The Java Programming Language

Las excepciones de tipo Unchecked son subclases que heredan desde RuntimeException. Además este tipo de excepciones no tienen la obligación de ser declaradas con la cláusula throws en la cabecera del método. Otra característica es que tampoco se tiene la obligación de atraparlas con un catch como se muestra en el ejemplo siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* Ejemplo de exception tipo {@link RuntimeException}
*
*/
public class RuntimeDemo {

/**
* Método principal
*/
public void mainMethod() {
methodThowsRuntimeException();
methodThowsRuntimeException2();
}

/**
* Este método lanzará una excepción de tipo runtime no declarada en su
* firma
*/
public void methodThowsRuntimeException() {
throw new ExampleRuntimeException();
}

/**
* Este método lanzará una excepción de tipo runtime está declarada en su
* firma (no es obligación) pero deja mas claro al desarrollador las
* excepciones que debería manejar con la API.
*
* @throws ExampleRuntimeException
* en caso de error
*/
public void methodThowsRuntimeException2() throws ExampleRuntimeException {
throw new ExampleRuntimeException();
}

/**
* Clase de error tipo Runtime
*/
public static class ExampleRuntimeException extends RuntimeException {

public ExampleRuntimeException() {
super();
}
}
}

Checked Exception

Este tipo de excepciones representan condiciones inválidas en el contexto de la línea de ejecución y que están fuera del control de dicho contexto, como por ejemplo, problemas con la base de datos, problemas de red, acceso a los archivos. También pueden ser condiciones de ingreso al sistema en donde el sistema no tiene ninguna participación, como por ejemplo, ingresar un nombre de usuario y contraseña incorrectos.

Contexto de ejecución ó scope: Corresponde al las líneas de código que están encerradas en un bloque de código, como por ejemplo, un método, un try/catch, bloque estático, etc.

Este tipo de excepciones deben ser declaradas en la firma del método. Además deben ser atrapadas dentro de los bloques de código donde se invoque un método que contenga la clausula throws.

Todas las excepciones de este tipo son subclases que heredan desde Exception, como por ejemplo:

Otra característica de este tipo de excepciones es que existe una probabilidad de recuperación de la ejecución y el método puede realizar alguna acción correctiva y/o informativa (log) en el bloque catch o simplemente relanzar la excepción y confiar en que el método invocante la atrape y haga algo con ella.

Para enviar el stacktrace al sistema de log (systemout) pueden usar los métodos de Throwable y en específico al método printStackTrace.

Un pequeño ejemplo de checked exception:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* Clase de ejemplo para checked exceptions
*/
public class CheckedExample {

/**
* Método que atrapa una checked exception
*/
public void catchCheckedException() {
try {
throwCheckedException();
} catch (ExampleCheckedException e) {
// TODO: hacer algo en caso de error (recuperacion)
}
}

/**
* Método que relanza la checked exception a un método superior
*
* @throws ExampleCheckedException
* checked exception error
*/
public void rethrowCheckedException() throws ExampleCheckedException {
throwCheckedException();
}

/**
* Método que lanza una checked exception
*
* @throws ExampleCheckedException
* checked exception error
*/
private void throwCheckedException() throws ExampleCheckedException {
throw new ExampleCheckedException();
}

/**
* Clase que representa la excepcion de ejemplo
*/
private static final class ExampleCheckedException extends Exception {

public ExampleCheckedException() {
super();
}

}
}

Error

Las excepciones de tipo Error son excepciones en las que el sistema no puede hacer nada con ellas, son clasificadas como errores irreversibles y que en su mayoría provienen desde la JVM, como por ejemplo: IOError, NoClassDefFoundError, NoSuchMethodError, OutOfMemoryError y VirtualMachineError por mencionar algunos de los errores.

Un poco de diseño y consejos para el manejo de excepciones

Para iniciar esta última parte, comenzaré con algunos ejemplos de malas prácticas con las que siempre nos encontramos cuando programamos, les dejo unas pocas:

1
2
3
4
5
try {
//ejecución que lanza checked exceptions
} catch (ExampleCheckedException e) {
// No hace nada
}

En el ejemplo de arriba claramente no se hace nada con la excepción dentro del try, lo recomendable es que si de verdad no vas a hacer nada con la excepción, al menos debes enviarla a tu sistema de Logger favorito con algún nivel de debug aceptable para poder revisar el log. Siempre se recomienda hacer algo en el bloque catch ya que ocurrió un error que debe ser controlado.

1
2
3
4
5
try {
//ejecución que lanza checked exceptions
} catch (ExampleCheckedException e) {
throw e;
}

Es similar ejemplo 1 pero lo que se hace es relanzar la excepción atrapada hacia el método invocante. La recomendación es que nunca hagas eso ya que se presta para confusión al leer el código fuente y en la practica se estarían ejecutando dos bloques catch para la misma excepción (el directo y el del método invocante).

1
2
3
4
5
try {
//ejecución que lanza checked exceptions
} catch (Exception e) {
// alguna lógica de negocio
}

Es recomendable que nunca atrapen todas las excepciones en un bloque catch y básicamente porque uno pierde la noción de por qué se produjo la excepción. Además en ese bloque también se atrapan las excepciones de tipo Runtime y ya mencionamos que estas excepciones significan errores en tu programa y que deben ser depurados (no escondidos debajo de la alfombra). Lo mejor es atrapar cada una de las excepciones y darle un tratamiento a cada una, si necesitan agrupar usen la herencia/jerarquía de las excepciones.

1
2
3
4
5
6
7
8
9
10
11
try {
//ejecución que lanza checked exceptions
} catch (Exception e) {
// alguna lógica de negocio

if (e instanceof BlaException ){

} else if (e instanceof FooException) {

} else if .....
}

Ese bloque de if’s compuestos se debe transformar en varios catch para cada una de las excepciones lanzadas. No usen un control de errores manual, es mejor usar las herramientas que te provee el lenguaje.

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
//ejecución que lanza checked exceptions
} catch (ErrorBlaException e) {
...
} catch (ErrorFooException e) {
...
} catch (ErrorOMGException e) {
...
} catch (SDWException e) {
...
} catch (Exception e) {
...
}

No abusar de las checked exceptions ya que hacen nuestro código confuso y poco mantenible. Si bien es cierto, es la herramienta que nos provee el lenguaje, no abusemos de ella y convirtamos los bloques catch en pseudo programas y rutinas anexas a la lógica de negocio (que es la que vale).

1
2
3
private void foo() throws Exception {
// código de negocio
}

Nunca lancen Exception como una excepción de su lógica de negocio y es que básicamente los catch están pensados en atrapar excepciones particulares y al lanzar Exception (de la mas alta jerarquía) jamás entrarás al bloque catch que corresponda y que pueda gestionar el error. Por otro lado el programador pierde la visibilidad de los errores particulares que debe gestionar.

1
2
3
4
5
6
7
private void foo() throws Exception {
try {
// logica de negocio
} catch (ParserConfigurationException e) {
throw new RuntimeException("Error");
}
}

Jamás se debe hacer esto, jamás!. Esto romperá todo tu programa ya que al lanzar la excepción RuntimeException esta llegará sin control a la capa mas alta provocando un error. Recuerden que ese tipo de excepciones son errores sin recuperación y justamente estamos tratando de hacer lo contrario gestionar los errores de lógica de negocio.

Mantener un árbol de excepciones

Esta sección del post quizás sea el más polémico ya que no hay receta perfecta para el manejo de excepciones y daré mis consejos (personales), puede que estén de acuerdo como puede que no.

Les recomiendo siempre mantener un árbol de excepciones que representen los errores (de negocio y de ejecución) de tu aplicación. Para que sea mas simple la mantención del árbol de excepciones, usen polimorfismo, herencia y todas las herramientas que ofrece OOP. En este punto siempre hay detractores de los árboles de excepciones con la excusa de su mantención.

Contraria a mi propuesta de manejo de errores, existen quienes mantienen sólo 1 excepción y a dicha excepción le agregan atributos y cuanta metadata puedan agregar. Qué se consigue finalmente con ese esquema de errores, es llenarte de IF por todos lados mirando los atributos que contiene la instancia de excepción y haciendo todo un control de errores manual.

Palabras al cierre

Quedan muchas cosas por mencionar de las excepciones y ahondar mucho mas en cómo diseñar y construir un árbol de excepciones, creo que sera materia para otro artículo. Demás esta decirles que esta abierta la discusión. Los comentarios bienvenidos sean.