Skip to content

Commit a380250

Browse files
authored
Error codes (encode#4550)
Add error codes to `APIException`
1 parent 5f6ceee commit a380250

File tree

9 files changed

+319
-94
lines changed

9 files changed

+319
-94
lines changed

docs/api-guide/exceptions.md

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Note that the exception handler will only be called for responses generated by r
9898

9999
The **base class** for all exceptions raised inside an `APIView` class or `@api_view`.
100100

101-
To provide a custom exception, subclass `APIException` and set the `.status_code` and `.default_detail` properties on the class.
101+
To provide a custom exception, subclass `APIException` and set the `.status_code`, `.default_detail`, and `default_code` attributes on the class.
102102

103103
For example, if your API relies on a third party service that may sometimes be unreachable, you might want to implement an exception for the "503 Service Unavailable" HTTP response code. You could do this like so:
104104

@@ -107,82 +107,114 @@ For example, if your API relies on a third party service that may sometimes be u
107107
class ServiceUnavailable(APIException):
108108
status_code = 503
109109
default_detail = 'Service temporarily unavailable, try again later.'
110+
default_code = 'service_unavailable'
111+
112+
#### Inspecting API exceptions
113+
114+
There are a number of different properties available for inspecting the status
115+
of an API exception. You can use these to build custom exception handling
116+
for your project.
117+
118+
The available attributes and methods are:
119+
120+
* `.detail` - Return the textual description of the error.
121+
* `.get_codes()` - Return the code identifier of the error.
122+
* `.full_details()` - Return both the textual description and the code identifier.
123+
124+
In most cases the error detail will be a simple item:
125+
126+
>>> print(exc.detail)
127+
You do not have permission to perform this action.
128+
>>> print(exc.get_codes())
129+
permission_denied
130+
>>> print(exc.full_details())
131+
{'message':'You do not have permission to perform this action.','code':'permission_denied'}
132+
133+
In the case of validation errors the error detail will be either a list or
134+
dictionary of items:
135+
136+
>>> print(exc.detail)
137+
{"name":"This field is required.","age":"A valid integer is required."}
138+
>>> print(exc.get_codes())
139+
{"name":"required","age":"invalid"}
140+
>>> print(exc.get_full_details())
141+
{"name":{"message":"This field is required.","code":"required"},"age":{"message":"A valid integer is required.","code":"invalid"}}
110142

111143
## ParseError
112144

113-
**Signature:** `ParseError(detail=None)`
145+
**Signature:** `ParseError(detail=None, code=None)`
114146

115147
Raised if the request contains malformed data when accessing `request.data`.
116148

117149
By default this exception results in a response with the HTTP status code "400 Bad Request".
118150

119151
## AuthenticationFailed
120152

121-
**Signature:** `AuthenticationFailed(detail=None)`
153+
**Signature:** `AuthenticationFailed(detail=None, code=None)`
122154

123155
Raised when an incoming request includes incorrect authentication.
124156

125157
By default this exception results in a response with the HTTP status code "401 Unauthenticated", but it may also result in a "403 Forbidden" response, depending on the authentication scheme in use. See the [authentication documentation][authentication] for more details.
126158

127159
## NotAuthenticated
128160

129-
**Signature:** `NotAuthenticated(detail=None)`
161+
**Signature:** `NotAuthenticated(detail=None, code=None)`
130162

131163
Raised when an unauthenticated request fails the permission checks.
132164

133165
By default this exception results in a response with the HTTP status code "401 Unauthenticated", but it may also result in a "403 Forbidden" response, depending on the authentication scheme in use. See the [authentication documentation][authentication] for more details.
134166

135167
## PermissionDenied
136168

137-
**Signature:** `PermissionDenied(detail=None)`
169+
**Signature:** `PermissionDenied(detail=None, code=None)`
138170

139171
Raised when an authenticated request fails the permission checks.
140172

141173
By default this exception results in a response with the HTTP status code "403 Forbidden".
142174

143175
## NotFound
144176

145-
**Signature:** `NotFound(detail=None)`
177+
**Signature:** `NotFound(detail=None, code=None)`
146178

147179
Raised when a resource does not exists at the given URL. This exception is equivalent to the standard `Http404` Django exception.
148180

149181
By default this exception results in a response with the HTTP status code "404 Not Found".
150182

151183
## MethodNotAllowed
152184

153-
**Signature:** `MethodNotAllowed(method, detail=None)`
185+
**Signature:** `MethodNotAllowed(method, detail=None, code=None)`
154186

155187
Raised when an incoming request occurs that does not map to a handler method on the view.
156188

157189
By default this exception results in a response with the HTTP status code "405 Method Not Allowed".
158190

159191
## NotAcceptable
160192

161-
**Signature:** `NotAcceptable(detail=None)`
193+
**Signature:** `NotAcceptable(detail=None, code=None)`
162194

163195
Raised when an incoming request occurs with an `Accept` header that cannot be satisfied by any of the available renderers.
164196

165197
By default this exception results in a response with the HTTP status code "406 Not Acceptable".
166198

167199
## UnsupportedMediaType
168200

169-
**Signature:** `UnsupportedMediaType(media_type, detail=None)`
201+
**Signature:** `UnsupportedMediaType(media_type, detail=None, code=None)`
170202

171203
Raised if there are no parsers that can handle the content type of the request data when accessing `request.data`.
172204

173205
By default this exception results in a response with the HTTP status code "415 Unsupported Media Type".
174206

175207
## Throttled
176208

177-
**Signature:** `Throttled(wait=None, detail=None)`
209+
**Signature:** `Throttled(wait=None, detail=None, code=None)`
178210

179211
Raised when an incoming request fails the throttling checks.
180212

181213
By default this exception results in a response with the HTTP status code "429 Too Many Requests".
182214

183215
## ValidationError
184216

185-
**Signature:** `ValidationError(detail)`
217+
**Signature:** `ValidationError(detail, code=None)`
186218

187219
The `ValidationError` exception is slightly different from the other `APIException` classes:
188220

rest_framework/authtoken/serializers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ def validate(self, attrs):
2121
# (Assuming the default `ModelBackend` authentication backend.)
2222
if not user.is_active:
2323
msg = _('User account is disabled.')
24-
raise serializers.ValidationError(msg)
24+
raise serializers.ValidationError(msg, code='authorization')
2525
else:
2626
msg = _('Unable to log in with provided credentials.')
27-
raise serializers.ValidationError(msg)
27+
raise serializers.ValidationError(msg, code='authorization')
2828
else:
2929
msg = _('Must include "username" and "password".')
30-
raise serializers.ValidationError(msg)
30+
raise serializers.ValidationError(msg, code='authorization')
3131

3232
attrs['user'] = user
3333
return attrs

rest_framework/exceptions.py

Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,61 @@
1717
from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList
1818

1919

20-
def _force_text_recursive(data):
20+
def _get_error_details(data, default_code=None):
2121
"""
2222
Descend into a nested data structure, forcing any
23-
lazy translation strings into plain text.
23+
lazy translation strings or strings into `ErrorDetail`.
2424
"""
2525
if isinstance(data, list):
2626
ret = [
27-
_force_text_recursive(item) for item in data
27+
_get_error_details(item, default_code) for item in data
2828
]
2929
if isinstance(data, ReturnList):
3030
return ReturnList(ret, serializer=data.serializer)
3131
return ret
3232
elif isinstance(data, dict):
3333
ret = {
34-
key: _force_text_recursive(value)
34+
key: _get_error_details(value, default_code)
3535
for key, value in data.items()
3636
}
3737
if isinstance(data, ReturnDict):
3838
return ReturnDict(ret, serializer=data.serializer)
3939
return ret
40-
return force_text(data)
40+
41+
text = force_text(data)
42+
code = getattr(data, 'code', default_code)
43+
return ErrorDetail(text, code)
44+
45+
46+
def _get_codes(detail):
47+
if isinstance(detail, list):
48+
return [_get_codes(item) for item in detail]
49+
elif isinstance(detail, dict):
50+
return {key: _get_codes(value) for key, value in detail.items()}
51+
return detail.code
52+
53+
54+
def _get_full_details(detail):
55+
if isinstance(detail, list):
56+
return [_get_full_details(item) for item in detail]
57+
elif isinstance(detail, dict):
58+
return {key: _get_full_details(value) for key, value in detail.items()}
59+
return {
60+
'message': detail,
61+
'code': detail.code
62+
}
63+
64+
65+
class ErrorDetail(six.text_type):
66+
"""
67+
A string-like object that can additionally
68+
"""
69+
code = None
70+
71+
def __new__(cls, string, code=None):
72+
self = super(ErrorDetail, cls).__new__(cls, string)
73+
self.code = code
74+
return self
4175

4276

4377
class APIException(Exception):
@@ -47,16 +81,35 @@ class APIException(Exception):
4781
"""
4882
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
4983
default_detail = _('A server error occurred.')
84+
default_code = 'error'
5085

51-
def __init__(self, detail=None):
52-
if detail is not None:
53-
self.detail = force_text(detail)
54-
else:
55-
self.detail = force_text(self.default_detail)
86+
def __init__(self, detail=None, code=None):
87+
if detail is None:
88+
detail = self.default_detail
89+
if code is None:
90+
code = self.default_code
91+
92+
self.detail = _get_error_details(detail, code)
5693

5794
def __str__(self):
5895
return self.detail
5996

97+
def get_codes(self):
98+
"""
99+
Return only the code part of the error details.
100+
101+
Eg. {"name": ["required"]}
102+
"""
103+
return _get_codes(self.detail)
104+
105+
def get_full_details(self):
106+
"""
107+
Return both the message & code parts of the error details.
108+
109+
Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
110+
"""
111+
return _get_full_details(self.detail)
112+
60113

61114
# The recommended style for using `ValidationError` is to keep it namespaced
62115
# under `serializers`, in order to minimize potential confusion with Django's
@@ -67,13 +120,21 @@ def __str__(self):
67120

68121
class ValidationError(APIException):
69122
status_code = status.HTTP_400_BAD_REQUEST
123+
default_detail = _('Invalid input.')
124+
default_code = 'invalid'
125+
126+
def __init__(self, detail, code=None):
127+
if detail is None:
128+
detail = self.default_detail
129+
if code is None:
130+
code = self.default_code
70131

71-
def __init__(self, detail):
72-
# For validation errors the 'detail' key is always required.
73-
# The details should always be coerced to a list if not already.
132+
# For validation failures, we may collect may errors together, so the
133+
# details should always be coerced to a list if not already.
74134
if not isinstance(detail, dict) and not isinstance(detail, list):
75135
detail = [detail]
76-
self.detail = _force_text_recursive(detail)
136+
137+
self.detail = _get_error_details(detail, code)
77138

78139
def __str__(self):
79140
return six.text_type(self.detail)
@@ -82,75 +143,74 @@ def __str__(self):
82143
class ParseError(APIException):
83144
status_code = status.HTTP_400_BAD_REQUEST
84145
default_detail = _('Malformed request.')
146+
default_code = 'parse_error'
85147

86148

87149
class AuthenticationFailed(APIException):
88150
status_code = status.HTTP_401_UNAUTHORIZED
89151
default_detail = _('Incorrect authentication credentials.')
152+
default_code = 'authentication_failed'
90153

91154

92155
class NotAuthenticated(APIException):
93156
status_code = status.HTTP_401_UNAUTHORIZED
94157
default_detail = _('Authentication credentials were not provided.')
158+
default_code = 'not_authenticated'
95159

96160

97161
class PermissionDenied(APIException):
98162
status_code = status.HTTP_403_FORBIDDEN
99163
default_detail = _('You do not have permission to perform this action.')
164+
default_code = 'permission_denied'
100165

101166

102167
class NotFound(APIException):
103168
status_code = status.HTTP_404_NOT_FOUND
104169
default_detail = _('Not found.')
170+
default_code = 'not_found'
105171

106172

107173
class MethodNotAllowed(APIException):
108174
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
109175
default_detail = _('Method "{method}" not allowed.')
176+
default_code = 'method_not_allowed'
110177

111-
def __init__(self, method, detail=None):
112-
if detail is not None:
113-
self.detail = force_text(detail)
114-
else:
115-
self.detail = force_text(self.default_detail).format(method=method)
178+
def __init__(self, method, detail=None, code=None):
179+
if detail is None:
180+
detail = force_text(self.default_detail).format(method=method)
181+
super(MethodNotAllowed, self).__init__(detail, code)
116182

117183

118184
class NotAcceptable(APIException):
119185
status_code = status.HTTP_406_NOT_ACCEPTABLE
120186
default_detail = _('Could not satisfy the request Accept header.')
187+
default_code = 'not_acceptable'
121188

122-
def __init__(self, detail=None, available_renderers=None):
123-
if detail is not None:
124-
self.detail = force_text(detail)
125-
else:
126-
self.detail = force_text(self.default_detail)
189+
def __init__(self, detail=None, code=None, available_renderers=None):
127190
self.available_renderers = available_renderers
191+
super(NotAcceptable, self).__init__(detail, code)
128192

129193

130194
class UnsupportedMediaType(APIException):
131195
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
132196
default_detail = _('Unsupported media type "{media_type}" in request.')
197+
default_code = 'unsupported_media_type'
133198

134-
def __init__(self, media_type, detail=None):
135-
if detail is not None:
136-
self.detail = force_text(detail)
137-
else:
138-
self.detail = force_text(self.default_detail).format(
139-
media_type=media_type
140-
)
199+
def __init__(self, media_type, detail=None, code=None):
200+
if detail is None:
201+
detail = force_text(self.default_detail).format(media_type=media_type)
202+
super(UnsupportedMediaType, self).__init__(detail, code)
141203

142204

143205
class Throttled(APIException):
144206
status_code = status.HTTP_429_TOO_MANY_REQUESTS
145207
default_detail = _('Request was throttled.')
146208
extra_detail_singular = 'Expected available in {wait} second.'
147209
extra_detail_plural = 'Expected available in {wait} seconds.'
210+
default_code = 'throttled'
148211

149-
def __init__(self, wait=None, detail=None):
150-
if detail is not None:
151-
self.detail = force_text(detail)
152-
else:
153-
self.detail = force_text(self.default_detail)
212+
def __init__(self, wait=None, detail=None, code=None):
213+
super(Throttled, self).__init__(detail, code)
154214

155215
if wait is None:
156216
self.wait = None

0 commit comments

Comments
 (0)