Saltar a contenido

Manejo global de errores#


iThome Ironman 2024

Esta es la entrega número 22 de la serie de tutoriales de Django Ninja.

En el artículo anterior aprendimos a utilizar HttpError y te recomendamos usarlo únicamente dentro de las funciones view.

Sin embargo, solo con esto, el manejo de errores de la API de nuestro proyecto está lejos de ser suficientemente completo; existen al menos 3 preguntas comunes por resolver:

  1. En los métodos de validación de un Schema, si no se debe hacer raise HttpError, ¿qué se debería hacer entonces?
  2. ¿Cómo deberíamos manejar otros tipos de errores, como errores en las operaciones con la base de datos?
  3. ¿Cómo garantizar que los formatos de respuesta ante errores en distintas API sean coherentes?

Todas estas preguntas apuntan a una necesidad más grande: necesitamos un mecanismo de manejo de errores integral.

Este artículo vendrá a responder a estas preguntas. Todos los cambios de código se pueden consultar en este PR.

Proyecto de ejemplo en GitHub#

👉 Django-Ninja-Tutorial


Cambiar al uso de ValidationError de Django#

¿Recuerdas cómo en los métodos de validación la versión original lanzaba un ValueError?

ValueError es capturado automáticamente por Django Ninja para devolver una respuesta 422; esto es algo bueno, pero no cumple con nuestras necesidades de personalización.

Por eso más tarde adoptamos HttpError, el cual, aunque también es capturado, ofrece un formato y contenido de respuesta más concisos, y además de mensajes de error permite personalizar el código de estado.

Sin embargo, como se explicó en la entrega anterior, aunque hacer esto es simple, no resulta adecuado.

¿Qué error se debería lanzar entonces?

Evitar el uso de errores provistos por Pydantic o Django Ninja#

En el artículo anterior también mencionamos que tanto Pydantic como Django Ninja poseen sus propios ValidationError integrados.

Sin embargo, están pensados más bien para uso interno del marco, además de que el formato del error devuelto es demasiado detallado y su forma de inicialización es muy quisquillosa. Por ejemplo, el error de validación de Django Ninja debe inicializarse así:

raise ValidationError(
    [{'loc': ('confirm_password',),
      'msg': 'La contraseña y la confirmación de contraseña deben ser iguales',
      'type': 'value_error'}])

Esto no es lo que conocemos como simplemente «pasar una cadena con el mensaje de error».

Por lo tanto, no recomiendo usar directamente estos tipos de errores dentro de la lógica de validación.

Por favor, usa ValidationError de Django#

En la lógica de validación de Schema, deberíamos usar preferentemente el ValidationError integrado de Django.

Su diseño ha considerado completamente las necesidades del desarrollador; su forma de inicializarse puede ser simple (usando una sola cadena de texto) o compleja (usando una list o un dict), adaptándose a la inmensa mayoría de los escenarios.

Aquí nos bastará con usar una cadena de texto para inicializarlo; el código corregido es el siguiente:

from django.core.exceptions import ValidationError

class CreateUserRequest(Schema):
    password: str
    confirm_password: str

    @model_validator(mode='after')
    def check_passwords_match(self):
        if self.password != self.confirm_password:
            raise ValidationError('La contraseña y la confirmación de contraseña deben ser iguales')

Se cambió el HttpError original por el ValidationError de Django.

Y se usó una «cadena de texto de mensaje de error» como forma de inicialización, prescindiendo del primer parámetro original: el «código de estado».


Django Ninja no manejará estos errores automáticamente#

Tras cambiar el tipo de error lanzado al ValidationError de Django, podrías notar un problema: ¡Django Ninja no capturará automáticamente estos errores!

Es decir, cuando lanzamos un ValidationError, Django Ninja no formateará automáticamente ni devolverá una respuesta de error 422 como lo hace al manejar HttpError, sino que dará un 500 directamente.

Esta parte la mencionamos al final del capítulo «Vol. 20: Validación de datos (Parte 2) Validación entre múltiples campos en Pydantic».

Ahora introduciremos la solución concreta: exception_handler.

Necesitamos manejar nosotros mismos estos errores lanzados, y es aquí donde entra en juego exception_handler.


Manejadores de errores globales: Exception Handlers#

Para tratar de forma unificada estos errores del mismo tipo provenientes de diferentes fuentes (sin limitarse a los métodos de validación de Schema), podemos utilizar el decorador @api.exception_handler provisto por Django Ninja.

Este decorador nos permite definir una lógica de respuesta exclusiva para «un tipo específico de error», aplicándola a todo el ámbito de la API.

Definir exception_handler#

Podemos definir un manejador global de errores para el ValidationError de Django, garantizando que cuando en cualquier lugar se lance este error, el handler lo capturará, haciendo que la API devuelva nuestro formato de respuesta personalizado.

En el archivo api.py del proyecto, añade el siguiente código:

# NinjaForum/api.py
from django.core.exceptions import ValidationError
from django.http import HttpRequest, HttpResponse
from ninja import NinjaAPI

api = NinjaAPI(...)

api.add_router(...)
api.add_router(...)

# Manejador de excepciones añadido
@api.exception_handler(exc_class=ValidationError)
def django_validation_error_handler(
    request: HttpRequest, exception: ValidationError
) -> HttpResponse:
    """
    Manejar la excepción ValidationError de Django
    """
    return api.create_response(
        request, {'detail': exception.message}, status=400
    )

Definimos una función manejadora de excepciones que, al encontrarse con un ValidationError de Django, devolverá una respuesta HTTP 400 conteniendo el mensaje de error personalizado, manteniendo así la coherencia del formato de respuesta.

El código es sencillo, pero los puntos clave no son pocos; analicémoslos uno a uno.


Análisis de los puntos clave de los Exception Handlers#

Comencemos hablando del tema de la «organización del proyecto».

1. ¿Dónde es mejor colocar la función Exception Handler?#

Como se dijo antes, el alcance de influencia de este manejador de errores es global, por lo que se puede colocar en cualquier lugar del proyecto.

Sin embargo, se recomienda colocarlo en la ubicación más adecuada. Considero que hay principalmente dos opciones:

  1. Si tus funciones manejadoras de errores no son muchas, puedes colocarla directamente en el api.py del proyecto (como lo hicimos en nuestro ejemplo). Esto concuerda con la naturaleza de gestión global del api.py del proyecto.
  2. Si hay muchos manejadores de errores, se recomienda independizar un módulo de Python para gestionarlos.

2. Nombres de función y parámetros#

Volvemos a mi disfrutada sección de «nombres» ☺️

Un Exception handler es una función (decorada) que en teoría debería seguir la convención de nombres de funciones de «comenzar con un verbo».

Sin embargo, utilicé un nombre inclinado hacia lo «sustantivo» como django_validation_error_handler.

Porque su naturaleza está más cercana a un dispositivo de procesamiento o mecanismo, que a una función en el sentido tradicional.

¡Por supuesto, esto depende de la perspectiva desde donde lo mires! También podrías argumentar que tiene una acción de procesamiento y por ende debe nombrarse comenzando con un verbo. Estoy totalmente de acuerdo.

Luego está el parámetro exception; la documentación de Django Ninja suele nombrarlo exc, lo cual personalmente no me gusta nada, porque considero que exc no es intuitivo en absoluto y es una abreviatura totalmente innecesaria.

Dando un paso atrás, preferiría usar una sola letra e, similar a la v en los métodos de validación de Pydantic.

3. Análisis de la lógica de la función#

La lógica de la función Exception handler puede ser larga o corta, simple o compleja, pero no sale de hacer estas dos cosas:

  1. Recibir un tipo de error específico.
  2. Devolver una respuesta HTTP específica.

En este ejemplo, recibimos el ValidationError de Django y devolvemos una respuesta «400 Bad Request», donde el contenido del mensaje de error proviene del error lanzado (definido por nosotros mismos).

Esta flexibilidad en el manejo de errores se puede considerar bastante buena. Si tu ValidationError utiliza una list o un dict para inicializarse, entonces esta función manejadora deberá escribirse de forma algo más compleja.


Pon a prueba tus habilidades: ejemplo con el manejo de respuestas 404#

Implementemos ahora otro exception handler para manejar el común error 404.

Tomando como ejemplo la API «obtener la información de una sola publicación»:

@router.get(...)
def get_post(request: HttpRequest, post_id: int) -> Post:
    """
    Obtener información de una sola publicación
    """
    post = Post.objects.get(id=post_id)
    return post

Actualmente, si la id de la publicación ingresada por el frontend no existe, el servidor dará un 500 directamente:

raise self.model.DoesNotExist( post.models.Post.DoesNotExist: Post matching query does not exist.

Y además expone mensajes internos: ¡esto es realmente ridículo! 🤣

Porque el método get de QuerySet en Django ORM lanza un error tanto al no encontrar resultados como al hallar múltiples resultados. Y como no hemos capturado ni manejado estos errores, el servidor da 500 directamente.

Ambos errores no son iguales; sus mensajes de error también deben ser distintos. Por ahora manejemos únicamente la primera situación.

Devolver una respuesta 404 al no encontrar resultados#

Tras la presentación de estas dos entregas, tienes dos formas de responder con un 404.

Primero, usar directamente HttpError:

try:
    post = Post.objects.get(id=post_id)
except Post.DoesNotExist:
    raise HttpError(404, 'La publicación no existe')

Esta es la forma que usamos en el artículo anterior y es sumamente recomendable.

Segundo, usar un exception handler:

# NinjaForum/api.py
from django.core.exceptions import ObjectDoesNotExist
...

@api.exception_handler(exc_class=ObjectDoesNotExist)
def object_does_not_exist_handler(
    request: HttpRequest, exception: ObjectDoesNotExist
) -> HttpResponse:
    """
    Manejar la excepción ObjectDoesNotExist de Django
    """
    return api.create_response(
        request, {'detail': 'No se encontraron datos'}, status=404)

Este enfoque tiene ventajas y desventajas en comparación con el primero:

  • Ventaja: no requiere modificar el contenido de la función view (escritura más concisa) y permite capturar los errores ObjectDoesNotExist lanzados por todas las API (es la clase padre de Post.DoesNotExist).
  • Desventaja: no se puede personalizar un mensaje de error «detallado», ya que no sabemos sobre qué objeto modelo ocurrió el ObjectDoesNotExist.
    • ¡Por supuesto, si estás dispuesto a definir un exception handler para cada error diferente, podrías lograrlo! Por ejemplo, si solo capturas Post.DoesNotExist, el mensaje de error puede decir «La publicación no existe».
    • ¡Pero eso requeriría definir muchos exception handlers, lo cual es algo engorroso!

Elegir el primer o segundo método requerirá que decidas según la situación.

Efecto de la respuesta 404#

  1. Usando HttpError:
// 404 Not Found
{
    "detail": "La publicación no existe"
}
  1. Usando exception handler:
// 404 Not Found
{
    "detail": "No se encontraron datos"
}

Resumen del capítulo 5#

A decir verdad, el capítulo 5 contiene una enorme cantidad de información. ¡Me tomó mucho tiempo escribir estos 4 artículos, e incluso los «refactoricé»! Originalmente iban a ser solo 2 entregas.

Discutimos primero cómo personalizar la validación de un solo campo y la validación entre múltiples campos. Luego aprendimos de forma gradual cómo manejar los errores lanzados por la API: cada vez más elegante y completo.

Si has estado leyendo desde la primera entrega hasta aquí, de verdad, puedes sentirte completamente orgulloso de ti mismo.

Siguientes pasos#

A continuación, ¿las cosas se vuelven más relajadas? — Para nada.

Presentaremos funcionalidades avanzadas comunes de las API.

Estas funciones, en comparación con el procesamiento de solicitudes y respuestas, se puede decir que «no necesariamente» tienen que estar presentes, pero siguen siendo sumamente importantes para muchos proyectos de API.

En el próximo capítulo, exploraremos estas funcionalidades avanzadas una a una y aprenderemos cómo implementar en Django Ninja. ¡Continuemos profundizando en el mundo del desarrollo de API!