Skip to content

Commit a28b543

Browse files
authored
chore: Improve errors.py (#100)
* Let ServerError take an `extra_message` argument This will fix an error we have in our usage of this error. * Use f-strings throughout * Use enum value rather than bare int for error code * Update CHANGELOG * Fix bad docs conf value * Add some unit tests for ApiError * Toopher -> DDA in comment * Use "additional_info" instead of "extra_message" for consistency * Expand testing * Make message formatting standard * Blacken * Minor change in error message * Improve error_code property a bit * Test ClientErrorResponseWrapper and make a small tweak * Add more tests * Blacken * Update CHANGELOG * Bump coverage check to 91% * Only log additional_info Requires running tests with logging on
1 parent 37b281e commit a28b543

File tree

7 files changed

+147
-25
lines changed

7 files changed

+147
-25
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
# [unreleased] - XXXX-XX-XX
8+
### Added
9+
- [PR 100](https://github.com/salesforce/django-declarative-apis/pull/100) Improve `errors.py`
10+
711
# [0.23.1] - 2022-05-17
812
### Fixed
913
- [PR 98](https://github.com/salesforce/django-declarative-apis/pull/98) Fix GitHub publish action

django_declarative_apis/machinery/errors.py

+25-20
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
import http.client
1414

15-
# Start Toopher-specific codes at 600 to avoid conflict/confusion with HTTP status codes
15+
16+
# Start DDA-specific codes at 600 to avoid conflict/confusion with HTTP status codes
1617
import sys
1718

1819
logger = logging.getLogger(__name__)
@@ -75,10 +76,8 @@ def error_code(self):
7576
return self.__dict__["error_code"]
7677
except KeyError:
7778
raise AttributeError(
78-
"'{}' object has no attribute '{}'".format(
79-
ApiError.__name__, "error_code"
80-
)
81-
)
79+
f"'{ApiError.__name__}' object has no attribute 'error_code'"
80+
) from None # suppress reporting the KeyError
8281

8382
@error_code.setter
8483
def error_code(self, value):
@@ -113,7 +112,7 @@ def __init__(self, additional_info=None):
113112
http.client.responses.get(http.client.NOT_FOUND),
114113
)
115114
if additional_info:
116-
error_message += " : " + additional_info
115+
error_message += f": {additional_info}"
117116
super().__init__(
118117
error_code, error_message, http_status_code=http.client.NOT_FOUND
119118
)
@@ -123,7 +122,7 @@ class ClientErrorForbidden(ClientError):
123122
def __init__(self, additional_info=None, **kwargs):
124123
error_code, error_message = FORBIDDEN
125124
if additional_info:
126-
error_message += ": %s" % additional_info
125+
error_message += f": {additional_info}"
127126
super().__init__(
128127
error_code, error_message, http_status_code=http.client.FORBIDDEN, **kwargs
129128
)
@@ -147,21 +146,25 @@ class ClientErrorExternalServiceFailure(ClientError):
147146
def __init__(self, additional_info=None):
148147
error_code, error_message = EXTERNAL_REQUEST_FAILURE
149148
if additional_info:
150-
error_message += ": {0}".format(additional_info)
149+
error_message += f": {additional_info}"
151150
super().__init__(error_code, error_message)
152151

153152

154153
class ClientErrorRequestThrottled(ClientError):
155154
def __init__(self):
156155
error_code, error_message = REQUEST_THROTTLED
157-
super().__init__(error_code, error_message, http_status_code=429)
156+
super().__init__(
157+
error_code,
158+
error_message,
159+
http_status_code=http.HTTPStatus.TOO_MANY_REQUESTS,
160+
)
158161

159162

160163
class ClientErrorTimedOut(ClientError):
161164
def __init__(self, additional_info=None):
162165
error_code, error_message = TIMED_OUT
163166
if additional_info:
164-
error_message += ": %s" % additional_info
167+
error_message += f": {additional_info}"
165168
super().__init__(
166169
error_code, error_message, http_status_code=http.client.REQUEST_TIMEOUT
167170
)
@@ -170,7 +173,7 @@ def __init__(self, additional_info=None):
170173
class ClientErrorResponseWrapper(ClientError):
171174
def __init__(self, response):
172175
error_code = response.status_code
173-
error_message = response.content
176+
error_message = response.content.decode()
174177
status_code = response.status_code
175178
super().__init__(error_code, error_message, http_status_code=status_code)
176179

@@ -179,43 +182,45 @@ class ClientErrorExtraFields(ClientError):
179182
def __init__(self, extra_fields=None):
180183
error_code, error_message = EXTRA_FIELDS
181184
if extra_fields:
182-
error_message += ": %s" % ", ".join(extra_fields)
185+
error_message += f": {', '.join(extra_fields)}"
183186
super().__init__(error_code, error_message)
184187

185188

186189
class ClientErrorReadOnlyFields(ClientError):
187190
def __init__(self, read_only_fields=None):
188191
error_code, error_message = READ_ONLY_FIELDS
189192
if read_only_fields:
190-
error_message += ": %s" % ", ".join(read_only_fields)
193+
error_message += f": {', '.join(read_only_fields)}"
191194
super().__init__(error_code, error_message)
192195

193196

194197
class ClientErrorMissingFields(ClientError):
195198
def __init__(self, missing_fields=None, extra_message=None):
196199
error_code, error_message = MISSING_FIELDS
197200
if missing_fields:
198-
error_message += ": %s" % ", ".join(missing_fields)
201+
error_message += f": {', '.join(missing_fields)}"
199202
if extra_message:
200-
error_message += ": {0}".format(extra_message)
203+
error_message += f": {extra_message}"
201204
super().__init__(error_code, error_message)
202205

203206

204207
class ClientErrorInvalidFieldValues(ClientError):
205208
def __init__(self, invalid_fields=None, extra_message=None):
206209
error_code, error_message = INVALID_FIELD_VALUES
207210
if invalid_fields:
208-
error_message += ": %s" % ", ".join(invalid_fields)
211+
error_message += f": {', '.join(invalid_fields)}"
209212
if extra_message:
210-
error_message += ": {0}".format(extra_message)
213+
error_message += f": {extra_message}"
211214
super().__init__(error_code, error_message)
212215

213216

214217
class ServerError(ApiError):
215-
def __init__(self):
218+
def __init__(self, additional_info=None):
216219
error_code, error_message = LOGGED_SERVER_ERROR
217-
error_message = error_message.format(uuid.uuid4())
218-
logger.exception(error_message)
220+
logged_error = error_message = error_message.format(uuid.uuid4())
221+
if additional_info:
222+
logged_error += f": {additional_info}"
223+
logger.exception(logged_error)
219224

220225
self._cause = None
221226

docs/source/conf.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
#
8585
# This is also used if you do content translation via gettext catalogs.
8686
# Usually you set "language" from the command line for these cases.
87-
language = None
87+
language = 'en'
8888

8989
# There are two options for replacing |today|: either, you set today to some
9090
# non-false value, then it is used:

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,5 @@ exclude = '''
2323
omit = ["*/tests/*", "*/management/*", "*/migrations/*"]
2424

2525
[tool.coverage.report]
26-
fail_under = 88
26+
fail_under = 91
2727
exclude_lines = ["raise NotImplementedError"]

tests/machinery/test_base.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def resource(self):
7878
self.fail("This should have failed")
7979
except errors.ClientErrorNotFound as err:
8080
self.assertEqual(err.error_code, http.HTTPStatus.NOT_FOUND)
81-
self.assertEqual(err.error_message, "Not Found : dict instance not found")
81+
self.assertEqual(err.error_message, "Not Found: dict instance not found")
8282
self.assertEqual(err.status_code, http.HTTPStatus.NOT_FOUND)
8383
self.assertFalse(err.save_changes)
8484
self.assertEqual(err.extra_fields, {})

tests/machinery/test_errors.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import django.test
2+
from django import http
3+
4+
from django_declarative_apis.machinery import errors
5+
6+
7+
class ErrorTestCast(django.test.TestCase):
8+
def test_apierror_tuple(self):
9+
test_code, test_message = test_tuple = errors.HTTPS_REQUIRED
10+
err = errors.ApiError(error_tuple=test_tuple)
11+
self.assertEqual(test_code, err.error_code)
12+
self.assertEqual(test_message, err.error_message)
13+
self.assertDictEqual(
14+
err.as_dict(), dict(error_code=test_code, error_message=test_message)
15+
)
16+
17+
def test_apierror_code_and_message(self):
18+
test_code, test_message = 600, "test message"
19+
err = errors.ApiError(code=test_code, message=test_message)
20+
self.assertEqual(test_code, err.error_code)
21+
self.assertEqual(test_message, err.error_message)
22+
self.assertDictEqual(
23+
dict(error_code=test_code, error_message=test_message), err.as_dict()
24+
)
25+
26+
def test_apierror_bad_args(self):
27+
try:
28+
errors.ApiError()
29+
except errors.ApiError:
30+
errmsg = "ApiError requires arguments"
31+
self.fail(errmsg)
32+
except Exception: # this is the expected result
33+
pass
34+
else:
35+
self.fail("ApiError without arguments should raise an exception.")
36+
37+
def test_apierror_extra_args(self):
38+
test_code, test_message = test_tuple = errors.AUTHORIZATION_FAILURE
39+
err = errors.ApiError(error_tuple=test_tuple, foo="bar", baz="quux")
40+
self.assertDictEqual(
41+
dict(
42+
error_code=test_code, error_message=test_message, foo="bar", baz="quux"
43+
),
44+
err.as_dict(),
45+
)
46+
47+
def test_apierror_deprecated_error_code(self):
48+
test_err = (999, "This is a fake error code.")
49+
try:
50+
errors.DEPRECATED_ERROR_CODES[test_err[0]] = test_err[1]
51+
with self.assertRaises(ValueError):
52+
with self.assertWarns(DeprecationWarning):
53+
errors.ApiError(error_tuple=test_err)
54+
finally:
55+
del errors.DEPRECATED_ERROR_CODES[test_err[0]]
56+
57+
def test_apierror_error_code_attributeerror(self):
58+
test_error_tuple = (999, "This is a fake error code.")
59+
err = errors.ApiError(error_tuple=test_error_tuple)
60+
del err.__dict__["error_code"]
61+
with self.assertRaises(AttributeError):
62+
err.error_code
63+
64+
def test_additional_info_in_error(self):
65+
test_classes = [
66+
errors.ClientErrorUnprocessableEntity,
67+
errors.ClientErrorNotFound,
68+
errors.ClientErrorForbidden,
69+
errors.ClientErrorUnauthorized,
70+
errors.ClientErrorExternalServiceFailure,
71+
errors.ClientErrorTimedOut,
72+
]
73+
test_message = "Test additional info."
74+
for cls in test_classes:
75+
with self.subTest(cls):
76+
err = cls(additional_info=test_message)
77+
self.assertIn(test_message, err.error_message)
78+
79+
def test_servererror(self):
80+
test_additional_info = "Test additional info."
81+
with self.assertLogs(logger="django_declarative_apis.machinery.errors") as logs:
82+
err = errors.ServerError(additional_info=test_additional_info)
83+
self.assertIn("Server Error", err.error_message)
84+
self.assertNotIn(test_additional_info, err.error_message)
85+
self.assertIn("Server Error", logs.output[0])
86+
self.assertIn(test_additional_info, logs.output[0])
87+
88+
def test_clienterrorresponsewrapper(self):
89+
message = "It's gone!"
90+
response = http.HttpResponseGone(message)
91+
err = errors.ClientErrorResponseWrapper(response)
92+
self.assertEqual(response.status_code, err.error_code)
93+
self.assertEqual(message, err.error_message)
94+
95+
def test_clienterrorextrafields(self):
96+
err = errors.ClientErrorExtraFields(extra_fields=["foo", "bar", "baz"])
97+
self.assertIn("foo", err.error_message)
98+
self.assertIn("bar", err.error_message)
99+
self.assertIn("baz", err.error_message)
100+
101+
def test_clienterrorreadonlyfields(self):
102+
err = errors.ClientErrorReadOnlyFields(read_only_fields=["foo", "bar", "baz"])
103+
self.assertIn("foo", err.error_message)
104+
self.assertIn("bar", err.error_message)
105+
self.assertIn("baz", err.error_message)
106+
107+
def test_clienterrormissingfields(self):
108+
extra_message = "Test extra message."
109+
err = errors.ClientErrorMissingFields(
110+
missing_fields=["foo", "bar", "baz"], extra_message=extra_message
111+
)
112+
self.assertIn("foo", err.error_message)
113+
self.assertIn("bar", err.error_message)
114+
self.assertIn("baz", err.error_message)
115+
self.assertIn(extra_message, err.error_message)

tests/settings.py

-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121

2222
ROOT_URLCONF = "tests.urls"
2323

24-
TEST_RUNNER = "tests.testutils.NoLoggingTestRunner"
25-
2624
MIDDLEWARE = []
2725

2826
REQUIRE_HTTPS_FOR_OAUTH = False

0 commit comments

Comments
 (0)