El sistema de manejo de errores por defecto en Spring Boot es funcional, pero rara vez se ajusta a los requisitos de una aplicación empresarial. Para ofrecer una experiencia de usuario coherente y facilitar la depuración, es fundamental implementar un mecanismo centralizado que capture tanto errores de lógica de negocio como errores de infraestructura (404, 403, etc.).
1. Definición del Diccionario de Errores
Utilizaremos un enum para estandarizar los códigos de error y sus descripciones. Esto evita la dispersión de mensajes de error por todo el código fuente.
public enum CodigoEstado {
// Errores de Negocio
USUARIO_REQUERIDO("ERR_1001", "El nombre de usuario es obligatorio"),
PASSWORD_INVALIDO("ERR_1002", "La contraseña no cumple los requisitos"),
// Errores HTTP Estándar
PETICION_INCORRECTA("400", "Formato de solicitud no válido"),
NO_AUTORIZADO("401", "Sesión expirada o no válida"),
PROHIBIDO("403", "Acceso denegado"),
NO_ENCONTRADO("404", "El recurso solicitado no existe"),
// Errores de Sistema
ERROR_INTERNO("500", "Error interno en el servidor"),
SERVICIO_OCUPADO("503", "El sistema está saturado temporalmente"),
DESCONOCIDO("0000", "Se ha producido un error inesperado");
private final String id;
private final String descripcion;
CodigoEstado(String id, String descripcion) {
this.id = id;
this.descripcion = descripcion;
}
public String getId() { return id; }
public String getDescripcion() { return descripcion; }
}
2. Estructura de Respuesta Unificada
Es vital que el cliente (Frontend o API externa) reciba siempre la misma estructura de datos, independientemente de si la operación fue exitosa o fallida.
@Data
public class RespuestaApi<T> implements Serializable {
private T contenido;
private boolean exito;
private String mensaje;
private String codigo;
public static <T> RespuestaApi<T> ok(T datos) {
RespuestaApi<T> res = new RespuestaApi<>();
res.setContenido(datos);
res.setExito(true);
res.setMensaje("Operación realizada con éxito");
return res;
}
public static <T> RespuestaApi<T> fallar(CodigoEstado estado) {
RespuestaApi<T> res = new RespuestaApi<>();
res.setExito(false);
res.setCodigo(estado.getId());
res.setMensaje(estado.getDescripcion());
return res;
}
}
3. Excepciones Personalizadas
Creamos excepciones en tiempo de ejecución para diferenciar los fallos de lógica de los fallos estructurales del servidor.
public class NegocioException extends RuntimeException {
private final CodigoEstado estado;
public NegocioException(CodigoEstado estado) {
super(estado.getDescripcion());
this.estado = estado;
}
public CodigoEstado getEstado() {
return estado;
}
}
public class InfraestructuraException extends NegocioException {
public InfraestructuraException(String id, String detalle) {
super(CodigoEstado.DESCONOCIDO);
// Lógica extendida para errores de ruta o permisos
}
}
4. Captura de Errores de Ruta (404 y 403)
Para interceptar errores que ocurrren fuera del contexto de los controladores (como rutas inexistentes), debemos extender BasicErrorController.
@Controller
public class GestorErroresRuta extends BasicErrorController {
public GestorErroresRuta() {
super(new DefaultErrorAttributes(), new ErrorProperties());
}
@Override
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
procesarError(request);
return null;
}
@Override
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
procesarError(request);
return null;
}
private void procesarError(HttpServletRequest request) {
Map<String, Object> atributos = getErrorAttributes(request, ErrorAttributeOptions.defaults());
String status = atributos.get("status").toString();
String path = atributos.get("path").toString();
// Evitar lanzar excepciones para recursos estáticos faltantes
if (!path.contains(".") && !path.startsWith("/static/")) {
throw new NegocioException(mapearEstado(status));
}
}
private CodigoEstado mapearEstado(String status) {
return switch (status) {
case "404" -> CodigoEstado.NO_ENCONTRADO;
case "403" -> CodigoEstado.PROHIBIDO;
default -> CodigoEstado.ERROR_INTERNO;
};
}
}
5. Manejador Global con @RestControllerAdvice
Esta clase centrlaiza la captura de todas las excepciones lanzadas en la aplicación y decide si responder con un JSON o redirigir a una vista de error.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NegocioException.class)
public Object manejarNegocioException(NegocioException ex, HttpServletRequest request) {
return generarRespuesta(ex, RespuestaApi.fallar(ex.getEstado()), request);
}
@ExceptionHandler(Exception.class)
public Object manejarErrorGenerico(Exception ex, HttpServletRequest request) {
log.error("Error no controlado: ", ex);
return generarRespuesta(ex, RespuestaApi.fallar(CodigoEstado.ERROR_INTERNO), request);
}
private Object generarRespuesta(Exception e, RespuestaApi<?> body, HttpServletRequest request) {
// Verificar si la petición espera un JSON (AJAX)
String header = request.getHeader("X-Requested-With");
boolean esAjax = "XMLHttpRequest".equalsIgnoreCase(header);
if (esAjax || request.getHeader("Accept").contains("application/json")) {
return new ResponseEntity<>(body, HttpStatus.OK);
} else {
ModelAndView mav = new ModelAndView("error_page");
mav.addObject("mensaje", body.getMensaje());
mav.addObject("codigo", body.getCodigo());
return mav;
}
}
}
6. Ejemplo de Implementación en Controlador
A continuación, se muestra cómo se comportan los diferentes escenarios en un controlador de pruebas.
@RestController
@RequestMapping("/api/v1/demo")
public class DemoController {
@GetMapping("/ok")
public RespuestaApi<String> operacionExitosa() {
return RespuestaApi.ok("Datos procesados correctamente");
}
@GetMapping("/error-negocio")
public void forzarErrorNegocio() {
throw new NegocioException(CodigoEstado.USUARIO_REQUERIDO);
}
@GetMapping("/error-inesperado")
public void forzarNullPointer() {
String texto = null;
texto.length(); // Provoca NullPointerException
}
}