Coverage for api\exception_handler.py: 89.1%
68 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-30 20:58 +0200
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-30 20:58 +0200
1"""
2Manejador de excepciones unico de la API
4Antes de esto la API devolvia los errores en tres formatos distintos y el
5cliente tenia que saber cual le tocaba segun el endpoint y el tipo de fallo:
7 1. La mayoria de endpoints {"status": "error", "message": ..., "error_code": 6}
8 2. CustomAPIException {"error": {"code": "6", "message": ...}}
9 3. Validacion de campo de DRF {"password": [mensaje]}
11El formato 1 es el que construyen a mano las vistas, asi que es el que se va a tomar como ejemplo,
12se mantiene la compatibilidad con los otros dos
13El nuevo formato es:
14 {
15 "status": "error",
16 "message": "<texto para el usuario>",
17 "error_code": <entero>,
18 "error": {"code": "<entero>", "message": "<texto>"}, Se mantiene por compatibilidad
19 "fields": {"password": ["..."]},
20 "password": ["..."] Se mantiene por compatibilidad
21 }
22"""
23from django.core.exceptions import ValidationError as DjangoValidationError
24from rest_framework import status as http_status
25from rest_framework.exceptions import ErrorDetail
26from rest_framework.exceptions import ValidationError as DRFValidationError
27from rest_framework.views import exception_handler as drf_exception_handler
29from api.errors import ErrorCodes
31# Codigo de error por defecto segun el codigo HTTP, para lo que no trae uno propio
32STATUS_TO_ERROR_CODE = {
33 http_status.HTTP_400_BAD_REQUEST: ErrorCodes.VALIDATION_ERROR,
34 http_status.HTTP_401_UNAUTHORIZED: ErrorCodes.INVALID_CREDENTIALS,
35 http_status.HTTP_403_FORBIDDEN: ErrorCodes.INSUFICIENT_CREDENTIALS,
36 http_status.HTTP_404_NOT_FOUND: ErrorCodes.NOT_FOUND,
37 http_status.HTTP_405_METHOD_NOT_ALLOWED: ErrorCodes.METHOD_NOT_ALLOWED,
38 http_status.HTTP_429_TOO_MANY_REQUESTS: ErrorCodes.TOO_MANY_REQUESTS,
39}
42# Extrae un texto legible de cualquier forma que tenga el detalle de DRF
43def _first_message(detail):
44 """
45 El detalle puede ser una cadena, una lista de cadenas o un diccionario de
46 campo -> lista de cadenas
47 """
48 if isinstance(detail, dict):
49 # Si el diccionario trae un mensaje, es ese
50 if 'message' in detail:
51 message = _first_message(detail['message'])
52 if message: 52 ↛ 55line 52 didn't jump to line 55 because the condition on line 52 was always true
53 return message
55 for value in detail.values(): 55 ↛ 59line 55 didn't jump to line 59 because the loop on line 55 didn't complete
56 message = _first_message(value)
57 if message: 57 ↛ 55line 57 didn't jump to line 55 because the condition on line 57 was always true
58 return message
59 return None
60 if isinstance(detail, (list, tuple)):
61 for value in detail: 61 ↛ 65line 61 didn't jump to line 65 because the loop on line 61 didn't complete
62 message = _first_message(value)
63 if message: 63 ↛ 61line 63 didn't jump to line 61 because the condition on line 63 was always true
64 return message
65 return None
66 return str(detail) if detail is not None else None
69# Un error de campo con codigo required es un campo que falta, no un formato malo
70def _code_from_field_errors(detail):
71 for value in detail.values():
72 candidates = value if isinstance(value, (list, tuple)) else [value]
73 for item in candidates:
74 if isinstance(item, ErrorDetail) and item.code == 'required':
75 return ErrorCodes.MISSING_REQUIRED_FIELD
76 return ErrorCodes.VALIDATION_ERROR
79# Saca el codigo de error propio si la excepcion trae uno (CustomAPIException)
80def _explicit_error_code(detail):
81 if not isinstance(detail, dict):
82 return None
83 error = detail.get('error')
84 if not isinstance(error, dict) or 'code' not in error:
85 return None
86 try:
87 return int(error['code'])
88 except (TypeError, ValueError):
89 return None
92def unified_exception_handler(exc, context):
93 """
94 Se configura en REST_FRAMEWORK[EXCEPTION_HANDLER].
96 Devolver None significa "no lo se manejar", DRF deja entonces que la
97 excepcion suba y Django responda un 500. Se mantiene ese comportamiento
98 para todo lo que no sea una excepcion de DRF, para no enmascarar fallos de
99 programacion como si fueran errores de negocio
100 """
101 if isinstance(exc, DjangoValidationError):
102 detail = exc.message_dict if hasattr(exc, 'message_dict') else exc.messages
103 exc = DRFValidationError(detail)
105 response = drf_exception_handler(exc, context)
106 if response is None:
107 return None
109 detail = response.data
111 # Si ya viene en el formato bueno, no se toca
112 if isinstance(detail, dict) and 'error_code' in detail and 'status' in detail: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 return response
115 error_code = _explicit_error_code(detail)
116 message = _first_message(detail)
118 # Errores de validacion de campo: {"password": ["..."], "email": ["..."]}
119 field_errors = None
120 if error_code is None and isinstance(detail, dict) and 'detail' not in detail:
121 field_errors = {key: value for key, value in detail.items() if key != 'error'}
122 if field_errors: 122 ↛ 125line 122 didn't jump to line 125 because the condition on line 122 was always true
123 error_code = _code_from_field_errors(field_errors)
125 if error_code is None:
126 error_code = STATUS_TO_ERROR_CODE.get(
127 response.status_code, ErrorCodes.INTERNAL_SERVER_ERROR
128 )
130 body = {
131 "status": "error",
132 "message": message,
133 "error_code": error_code,
134 # Compatibilidad la anterior version que lanzaba el CustomAPIException
135 "error": {"code": str(error_code), "message": message},
136 }
138 if field_errors:
139 body["fields"] = field_errors
140 # Compatibilidad con la anterior version que leia la clave del campo directamente
141 for field, errors in field_errors.items():
142 body.setdefault(field, errors)
144 response.data = body
145 return response