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

1""" 

2Manejador de excepciones unico de la API 

3 

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: 

6 

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]} 

10 

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 

28 

29from api.errors import ErrorCodes 

30 

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} 

40 

41 

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 

54 

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 

67 

68 

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 

77 

78 

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 

90 

91 

92def unified_exception_handler(exc, context): 

93 """ 

94 Se configura en REST_FRAMEWORK[EXCEPTION_HANDLER]. 

95 

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) 

104 

105 response = drf_exception_handler(exc, context) 

106 if response is None: 

107 return None 

108 

109 detail = response.data 

110 

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 

114 

115 error_code = _explicit_error_code(detail) 

116 message = _first_message(detail) 

117 

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) 

124 

125 if error_code is None: 

126 error_code = STATUS_TO_ERROR_CODE.get( 

127 response.status_code, ErrorCodes.INTERNAL_SERVER_ERROR 

128 ) 

129 

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 } 

137 

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) 

143 

144 response.data = body 

145 return response