Skip to content

Commit 60622fc

Browse files
Improve ApiError message formatting; add response field to ApiError and UnexpectedResponseError (linode#467)
* Improve ApiError and UnexpectedResponseError * make format * Fix again * Remove comma from multiline string
1 parent 0139b51 commit 60622fc

File tree

7 files changed

+269
-39
lines changed

7 files changed

+269
-39
lines changed

linode_api4/errors.py

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
# Necessary to maintain compatibility with Python < 3.11
2+
from __future__ import annotations
3+
14
from builtins import super
5+
from json import JSONDecodeError
6+
from typing import Any, Dict, Optional
7+
8+
from requests import Response
29

310

411
class ApiError(RuntimeError):
@@ -8,14 +15,90 @@ class ApiError(RuntimeError):
815
often, this will be caused by invalid input to the API.
916
"""
1017

11-
def __init__(self, message, status=400, json=None):
18+
def __init__(
19+
self,
20+
message: str,
21+
status: int = 400,
22+
json: Optional[Dict[str, Any]] = None,
23+
response: Optional[Response] = None,
24+
):
1225
super().__init__(message)
26+
1327
self.status = status
1428
self.json = json
29+
self.response = response
30+
1531
self.errors = []
32+
1633
if json and "errors" in json and isinstance(json["errors"], list):
1734
self.errors = [e["reason"] for e in json["errors"]]
1835

36+
@classmethod
37+
def from_response(
38+
cls,
39+
response: Response,
40+
message: Optional[str] = None,
41+
disable_formatting: bool = False,
42+
) -> Optional[ApiError]:
43+
"""
44+
Creates an ApiError object from the given response,
45+
or None if the response does not contain an error.
46+
47+
:arg response: The response to create an ApiError from.
48+
:arg message: An optional message to prepend to the error's message.
49+
:arg disable_formatting: If true, the error's message will not automatically be formatted
50+
with details from the API response.
51+
52+
:returns: The new API error.
53+
"""
54+
55+
if response.status_code < 400 or response.status_code > 599:
56+
# No error was found
57+
return None
58+
59+
request = response.request
60+
61+
try:
62+
response_json = response.json()
63+
except JSONDecodeError:
64+
response_json = None
65+
66+
# Use the user-defined message is formatting is disabled
67+
if disable_formatting:
68+
return cls(
69+
message,
70+
status=response.status_code,
71+
json=response_json,
72+
response=response,
73+
)
74+
75+
# Build the error string
76+
error_fmt = "N/A"
77+
78+
if response_json is not None and "errors" in response_json:
79+
errors = []
80+
81+
for error in response_json["errors"]:
82+
field = error.get("field")
83+
reason = error.get("reason")
84+
errors.append(f"{field + ': ' if field else ''}{reason}")
85+
86+
error_fmt = "; ".join(errors)
87+
88+
elif len(response.text or "") > 0:
89+
error_fmt = response.text
90+
91+
return cls(
92+
(
93+
f"{message + ': ' if message is not None else ''}"
94+
f"{f'{request.method} {request.path_url}: ' if request else ''}"
95+
f"[{response.status_code}] {error_fmt}"
96+
),
97+
status=response.status_code,
98+
json=response_json,
99+
response=response,
100+
)
101+
19102

20103
class UnexpectedResponseError(RuntimeError):
21104
"""
@@ -26,7 +109,41 @@ class UnexpectedResponseError(RuntimeError):
26109
library, and should be fixed with changes to this codebase.
27110
"""
28111

29-
def __init__(self, message, status=200, json=None):
112+
def __init__(
113+
self,
114+
message: str,
115+
status: int = 200,
116+
json: Optional[Dict[str, Any]] = None,
117+
response: Optional[Response] = None,
118+
):
30119
super().__init__(message)
120+
31121
self.status = status
32122
self.json = json
123+
self.response = response
124+
125+
@classmethod
126+
def from_response(
127+
cls,
128+
message: str,
129+
response: Response,
130+
) -> Optional[UnexpectedResponseError]:
131+
"""
132+
Creates an UnexpectedResponseError object from the given response and message.
133+
134+
:arg message: The message to create this error with.
135+
:arg response: The response to create an UnexpectedResponseError from.
136+
:returns: The new UnexpectedResponseError.
137+
"""
138+
139+
try:
140+
response_json = response.json()
141+
except JSONDecodeError:
142+
response_json = None
143+
144+
return cls(
145+
message,
146+
status=response.status_code,
147+
json=response_json,
148+
response=response,
149+
)

linode_api4/linode_client.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -287,23 +287,9 @@ def _api_call(
287287
if warning:
288288
logger.warning("Received warning from server: {}".format(warning))
289289

290-
if 399 < response.status_code < 600:
291-
j = None
292-
error_msg = "{}: ".format(response.status_code)
293-
try:
294-
j = response.json()
295-
if "errors" in j.keys():
296-
for e in j["errors"]:
297-
msg = e.get("reason", "")
298-
field = e.get("field", None)
299-
300-
error_msg += "{}{}; ".format(
301-
f"[{field}] " if field is not None else "",
302-
msg,
303-
)
304-
except:
305-
pass
306-
raise ApiError(error_msg, status=response.status_code, json=j)
290+
api_error = ApiError.from_response(response)
291+
if api_error is not None:
292+
raise api_error
307293

308294
if response.status_code != 204:
309295
j = response.json()

linode_api4/login_client.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -434,10 +434,9 @@ def oauth_redirect():
434434
)
435435

436436
if r.status_code != 200:
437-
raise ApiError(
438-
"OAuth token exchange failed",
439-
status=r.status_code,
440-
json=r.json(),
437+
raise ApiError.from_response(
438+
r,
439+
message="OAuth token exchange failed",
441440
)
442441

443442
token = r.json()["access_token"]
@@ -479,7 +478,7 @@ def refresh_oauth_token(self, refresh_token):
479478
)
480479

481480
if r.status_code != 200:
482-
raise ApiError("Refresh failed", r)
481+
raise ApiError.from_response(r, message="Refresh failed")
483482

484483
token = r.json()["access_token"]
485484
scopes = OAuthScopes.parse(r.json()["scopes"])
@@ -516,5 +515,5 @@ def expire_token(self, token):
516515
)
517516

518517
if r.status_code != 200:
519-
raise ApiError("Failed to expire token!", r)
518+
raise ApiError.from_response(r, "Failed to expire token!")
520519
return True

linode_api4/objects/account.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -436,8 +436,10 @@ def thumbnail(self, dump_to=None):
436436
)
437437

438438
if not result.status_code == 200:
439-
raise ApiError(
440-
"No thumbnail found for OAuthClient {}".format(self.id)
439+
raise ApiError.from_response(
440+
result,
441+
"No thumbnail found for OAuthClient {}".format(self.id),
442+
disable_formatting=True,
441443
)
442444

443445
if dump_to:
@@ -472,12 +474,9 @@ def set_thumbnail(self, thumbnail):
472474
data=thumbnail,
473475
)
474476

475-
if not result.status_code == 200:
476-
errors = []
477-
j = result.json()
478-
if "errors" in j:
479-
errors = [e["reason"] for e in j["errors"]]
480-
raise ApiError("{}: {}".format(result.status_code, errors), json=j)
477+
api_exc = ApiError.from_response(result)
478+
if api_exc is not None:
479+
raise api_exc
481480

482481
return True
483482

linode_api4/objects/support.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,9 @@ def upload_attachment(self, attachment: Union[Path, str]):
174174
files={"file": f},
175175
)
176176

177-
if not result.status_code == 200:
178-
errors = []
179-
j = result.json()
180-
if "errors" in j:
181-
errors = [e["reason"] for e in j["errors"]]
182-
raise ApiError("{}: {}".format(result.status_code, errors), json=j)
177+
api_exc = ApiError.from_response(result)
178+
if api_exc is not None:
179+
raise api_exc
183180

184181
return True
185182

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from linode_api4.errors import ApiError
2+
3+
4+
def test_error_404(test_linode_client):
5+
api_exc = None
6+
7+
try:
8+
test_linode_client.get("/invalid/endpoint")
9+
except ApiError as exc:
10+
api_exc = exc
11+
12+
assert str(api_exc) == "GET /v4beta/invalid/endpoint: [404] Not found"
13+
14+
15+
def test_error_400(test_linode_client):
16+
api_exc = None
17+
18+
try:
19+
test_linode_client.linode.instance_create(
20+
"g6-fake-plan", "us-fakeregion"
21+
)
22+
except ApiError as exc:
23+
api_exc = exc
24+
25+
assert str(api_exc) == (
26+
"POST /v4beta/linode/instances: [400] type: A valid plan type by that ID was not found; "
27+
"region: region is not valid"
28+
)

test/unit/errors_test.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from types import SimpleNamespace
2+
from unittest import TestCase
3+
4+
from linode_api4.errors import ApiError, UnexpectedResponseError
5+
6+
7+
class ApiErrorTest(TestCase):
8+
def test_from_response(self):
9+
mock_response = SimpleNamespace(
10+
status_code=400,
11+
json=lambda: {
12+
"errors": [
13+
{"reason": "foo"},
14+
{"field": "bar", "reason": "oh no"},
15+
]
16+
},
17+
text='{"errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}]}',
18+
request=SimpleNamespace(
19+
method="POST",
20+
path_url="foo/bar",
21+
),
22+
)
23+
24+
exc = ApiError.from_response(mock_response)
25+
26+
assert str(exc) == "POST foo/bar: [400] foo; bar: oh no"
27+
assert exc.status == 400
28+
assert exc.json == {
29+
"errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}]
30+
}
31+
assert exc.response.request.method == "POST"
32+
assert exc.response.request.path_url == "foo/bar"
33+
34+
def test_from_response_non_json_body(self):
35+
mock_response = SimpleNamespace(
36+
status_code=500,
37+
json=lambda: None,
38+
text="foobar",
39+
request=SimpleNamespace(
40+
method="POST",
41+
path_url="foo/bar",
42+
),
43+
)
44+
45+
exc = ApiError.from_response(mock_response)
46+
47+
assert str(exc) == "POST foo/bar: [500] foobar"
48+
assert exc.status == 500
49+
assert exc.json is None
50+
assert exc.response.request.method == "POST"
51+
assert exc.response.request.path_url == "foo/bar"
52+
53+
def test_from_response_empty_body(self):
54+
mock_response = SimpleNamespace(
55+
status_code=500,
56+
json=lambda: None,
57+
text=None,
58+
request=SimpleNamespace(
59+
method="POST",
60+
path_url="foo/bar",
61+
),
62+
)
63+
64+
exc = ApiError.from_response(mock_response)
65+
66+
assert str(exc) == "POST foo/bar: [500] N/A"
67+
assert exc.status == 500
68+
assert exc.json is None
69+
assert exc.response.request.method == "POST"
70+
assert exc.response.request.path_url == "foo/bar"
71+
72+
def test_from_response_no_request(self):
73+
mock_response = SimpleNamespace(
74+
status_code=500, json=lambda: None, text="foobar", request=None
75+
)
76+
77+
exc = ApiError.from_response(mock_response)
78+
79+
assert str(exc) == "[500] foobar"
80+
assert exc.status == 500
81+
assert exc.json is None
82+
assert exc.response.request is None
83+
84+
85+
class UnexpectedResponseErrorTest(TestCase):
86+
def test_from_response(self):
87+
mock_response = SimpleNamespace(
88+
status_code=400,
89+
json=lambda: {
90+
"foo": "bar",
91+
},
92+
request=SimpleNamespace(
93+
method="POST",
94+
path_url="foo/bar",
95+
),
96+
)
97+
98+
exc = UnexpectedResponseError.from_response("foobar", mock_response)
99+
100+
assert str(exc) == "foobar"
101+
assert exc.status == 400
102+
assert exc.json == {"foo": "bar"}
103+
assert exc.response.request.method == "POST"
104+
assert exc.response.request.path_url == "foo/bar"

0 commit comments

Comments
 (0)