Saltar a contenido

HttpError y respuestas personalizadas#

iThome Ironman 2024

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

En el desarrollo de software, el manejo de errores es un aspecto que no se puede ignorar, pero que a menudo se ignora.

Sin exagerar, el manejo de errores es un tema en el que «si lo haces bien nadie te alaba, pero si lo haces mal el sistema sufre las consecuencias».

No importa, de todos modos haremos nuestro mejor esfuerzo por hacerlo bien.

Django Ninja utiliza Pydantic para la validación de datos; cuando esta falla, responde por defecto con «422 Unprocessable Entity».

Sin embargo, a veces necesitamos responder con «400 Bad Request» u otros códigos de estado para cumplir con requerimientos reales del negocio o hábitos de desarrollo del equipo.

En resumen, sea cual sea la razón, queremos personalizar el mensaje de error, el formato y el código de estado de respuesta, en lugar de usar la respuesta 422 por defecto de Django Ninja (hay que admitir que esa respuesta estandarizada contiene demasiada información y una estructura un poco compleja, ya que debe ser compatible con múltiples situaciones).

Este artículo explicará cómo personalizar el manejo y las respuestas de error utilizando la clase HttpError integrada en Django Ninja.

Todos los cambios de código se pueden consultar en este PR.

Proyecto de ejemplo en GitHub#

👉 Django-Ninja-Tutorial


Manejo automático de errores en Django Ninja#

En la entrega anterior mencionamos que si lanzas un error ValueError dentro del método de validación de un Schema, Django Ninja lo capturará y responderá automáticamente.

De hecho, no solo ValueError, Django Ninja también manejará los siguientes tipos de errores por ti:

  • pydantic.ValidationError: errores de validación provenientes de Pydantic, lo cual es la razón por la que recibimos directamente una respuesta 422 cuando hay un problema en un campo de Schema.
  • Además, Django Ninja incluye un ninja.errors.ValidationError integrado; estos errores también devuelven 422.
  • ninja.errors.HttpError: este es el enfoque de este artículo y se explicará a continuación.

Todos estos son errores que Django Ninja captura automáticamente, pero no todos devuelven la respuesta estandarizada 422 (el tercer tipo no lo hace).


Nuevo requerimiento: usar respuesta 400 al fallar la validación#

Tomando como ejemplo la API «Crear usuario», implementaremos un nuevo requerimiento: cuando la confirmación de contraseña no coincida, se debe responder con «400 Bad Request» en lugar de 422.

¿Cuál es la forma más sencilla de hacerlo?

Respuesta: utilizar HttpError de Django Ninja.

A continuación se muestran los cambios en el código del Schema; ¡solo cambiamos dos líneas!

...
from ninja.errors import HttpError  # Primera línea

class CreateUserRequest(Schema):
    ...

    @model_validator(mode='after')
    def check_passwords_match(self) -> Self:
        if self.password != self.confirm_password:
            raise HttpError(400, 'La contraseña y la confirmación de contraseña deben ser iguales')  # Segunda línea
        return self

Así es, ¡tan simple como eso!

Solo basta con reemplazar el error lanzado en el método de validación de ValueError a HttpError.

Vale la pena señalar que la inicialización de una instancia de HttpError requiere dos parámetros: el primero es el código de estado HTTP y el segundo es el mensaje de error.

Contenido de la respuesta#

Veamos qué diferencia hay en la respuesta ante el mismo fallo de validación:

// 400 Bad Request
{
    "detail": "La contraseña y la confirmación de contraseña deben ser iguales"
}

Cambia al formato que nos resulta familiar: solo el mensaje de error.

Al comparar con la respuesta dada al lanzar ValueError en el artículo anterior:

// 422 Unprocessable Entity
{
    "detail": [
        {
            "type": "value_error",
            "loc": [
                "body",
                "payload",
                "confirm_password"
            ],
            "msg": "Value error, La contraseña y la confirmación de contraseña deben ser iguales",
            "ctx": {
                "error": "La contraseña y la confirmación de contraseña deben ser iguales"
            }
        }
    ]
}

Gran diferencia, ¿verdad?


La inconveniencia de usar HttpError en métodos de validación#

Lanzar HttpError directamente dentro del método de validación del Schema es una forma conveniente, ya que permite simplificar el procesamiento de la respuesta.

No necesitamos capturar errores adicionales ni especificar manualmente el formato de respuesta. Cuando la validación falla, la API responde directamente con el código de estado y mensaje de error que definimos, lo cual es simple y práctico.

Sin embargo, hacer esto no es del todo adecuado; existen varios problemas principales, como reducir la testeabilidad, limitar la flexibilidad de la respuesta, etc. Pero entre ellos, el más crítico es el que mencionamos en el artículo anterior: la «separación de responsabilidades».

Violación de la «separación de responsabilidades»#

Esta práctica viola el principio de «separación de responsabilidades».

La responsabilidad de la lógica de validación es verificar la corrección de los datos, mientras que la respuesta debe ser responsabilidad de la función view.

Mezclar la lógica de respuesta dentro del proceso de validación hace que dos partes que deberían ser independientes (validación y respuesta) se acoplen, provocando confusión de responsabilidades y perjudicando el mantenimiento del código.

Por ende, aunque usar HttpError dentro del método de validación parece una forma conveniente de lograr el requerimiento, considerando desde la perspectiva del diseño de arquitectura, colocar el procesamiento de la respuesta dentro de la función view es una opción mucho más razonable.

No te preocupes, en la siguiente entrega cambiaremos de enfoque, pero el protagonista de este artículo sigue siendo HttpError.


Escenario típico de HttpError: uso dentro de funciones view#

En lugar de usar HttpError dentro del Schema, ejecutarlo dentro de la función view es el camino correcto.

A continuación se muestra un escenario clásico.

Aunque la lógica de validación de datos deba colocarse en el Schema en la medida de lo posible, no todas las validaciones son adecuadas para delegarse al Schema.

Por ejemplo, el campo email del usuario tiene la propiedad de «unicidad» (no puede repetirse). Por ello, queremos verificar primero si el email ingresado por el usuario se duplica con datos en la base de datos y, de ser así, responder directamente con 409 Conflict.

Esto es, sin duda, una validación, pero involucra una «consulta a la base de datos».

Esta validación que involucra consultas a la base de datos es más adecuada para realizarse dentro de la función view que en el Schema. Esto se debe a que las consultas a la base de datos son operaciones dinámicas más pesadas, las cuales son esencialmente distintas del chequeo estático de datos del Schema.

Por lo tanto, es más frecuente usar HttpError dentro de las funciones view para manejar este tipo de requerimientos.

El nuevo código añadido es el siguiente:

@router.post(...)
def create_user(..., payload: CreateUserRequest):
    """
    Crear usuario
    """
    if User.objects.filter(email=payload.email).exists():
        raise HttpError(409, 'El email de usuario ya existe')
    ...

Lo anterior es una «consulta previa», la cual es similar en resultado a la siguiente forma de escribirlo:

try:
    user.save()
except IntegrityError:  # Error de unicidad de Django ORM
    raise HttpError(409, 'El email de usuario ya existe')

La única diferencia es que una es una validación previa con lanzamiento de error, mientras que la otra es capturar el error a posteriori (y luego lanzarlo).

Respuesta al fallar la validación:

// 409 Conflict
{
    "detail": "El email de usuario ya existe"
}

¡La verdad es que está bastante bien!


¿Por qué no simplemente hacer return de una respuesta 409?#

Tu lado ingenioso podría pensar:

Oye, ¿entonces por qué no simplemente retorno un diccionario de Python con el mensaje de error? ¿Por qué tengo que hacer raise HttpError en la función view?

Esa idea tendría un código aproximado como el siguiente:

@router.post('/users/', response={201: dict, 409: dict}, ...)
def (...) -> tuple[int, dict]:
    """
    Crear usuario
    """
    if User.objects.filter(email=payload.email).exists():
        return 409, {"detail": "El email de usuario ya existe"}
    ...

¿Acaso no es mucho más intuitivo así?

Esta es una excelente pregunta.

Análisis de los puntos clave#

Veamos primero qué puntos clave contiene este fragmento de código:

  • response={201: dict, 409: dict}: la «respuesta con múltiples códigos de estado» mencionada en la entrega 13; ¡mira cómo viene como anillo al dedo!
  • Reemplazar raise por return.
  • Si deseas validar el formato del mensaje de error, puedes definir un Schema. Este ejemplo es solo una versión simplificada.

Parece ser una buena alternativa y muy acorde a la intuición; de hecho, antes cuando escribía Django REST framework, siempre lo hacía así.

Sin embargo, esta forma de escribirlo en Django Ninja tropezará con pared al utilizar el decorador de paginación.

Como aún no es el momento adecuado, en el posterior capítulo «Vol. 25: Paginación (Parte 2) Clase de paginación personalizada» dejaremos este asunto bien en claro.

En resumen, en la etapa actual solo necesitamos saber que, en situaciones similares, hacer raise HttpError resulta más adecuado.


Resumen#

En este artículo, aprendimos cómo usar el HttpError integrado de Django Ninja para personalizar las respuestas de error y evitar el 422 por defecto.

Y explicamos por qué HttpError no es adecuado para usarse dentro de un Schema (aunque lo hayamos hecho temporalmente 😅), sino que debe colocarse dentro de las funciones view.

En la siguiente entrega, mejoraremos los errores lanzados por el Schema, exploraremos el mecanismo global de manejo de errores y utilizaremos el decorador exception_handler provisto por Django Ninja para elevar aún más la capacidad de manejo de errores de la API.