Skip to content

Commit 8375261

Browse files
authored
feat: add trace_id to API exceptions (#404)
- Do not use helper functions to raise exceptions, - Add the request trace ID to the API exceptions, - Refactor the code.
1 parent 93eb56b commit 8375261

File tree

3 files changed

+76
-34
lines changed

3 files changed

+76
-34
lines changed

hcloud/_client.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import time
4-
from typing import NoReturn, Protocol
4+
from typing import Protocol
55

66
import requests
77

@@ -190,20 +190,6 @@ def _get_headers(self) -> dict:
190190
}
191191
return headers
192192

193-
def _raise_exception_from_response(self, response: requests.Response) -> NoReturn:
194-
raise APIException(
195-
code=response.status_code,
196-
message=response.reason,
197-
details={"content": response.content},
198-
)
199-
200-
def _raise_exception_from_content(self, content: dict) -> NoReturn:
201-
raise APIException(
202-
code=content["error"]["code"],
203-
message=content["error"]["message"],
204-
details=content["error"]["details"],
205-
)
206-
207193
def request( # type: ignore[no-untyped-def]
208194
self,
209195
method: str,
@@ -229,23 +215,40 @@ def request( # type: ignore[no-untyped-def]
229215
**kwargs,
230216
)
231217

232-
content = {}
218+
trace_id = response.headers.get("X-Correlation-Id")
219+
payload = {}
233220
try:
234221
if len(response.content) > 0:
235-
content = response.json()
236-
except (TypeError, ValueError):
237-
self._raise_exception_from_response(response)
222+
payload = response.json()
223+
except (TypeError, ValueError) as exc:
224+
raise APIException(
225+
code=response.status_code,
226+
message=response.reason,
227+
details={"content": response.content},
228+
trace_id=trace_id,
229+
) from exc
238230

239231
if not response.ok:
240-
if content:
241-
assert isinstance(content, dict)
242-
if content["error"]["code"] == "rate_limit_exceeded" and _tries < 5:
243-
time.sleep(_tries * self._retry_wait_time)
244-
_tries = _tries + 1
245-
return self.request(method, url, _tries=_tries, **kwargs)
246-
247-
self._raise_exception_from_content(content)
248-
else:
249-
self._raise_exception_from_response(response)
250-
251-
return content
232+
if not payload or "error" not in payload:
233+
raise APIException(
234+
code=response.status_code,
235+
message=response.reason,
236+
details={"content": response.content},
237+
trace_id=trace_id,
238+
)
239+
240+
error: dict = payload["error"]
241+
242+
if error["code"] == "rate_limit_exceeded" and _tries < 5:
243+
time.sleep(_tries * self._retry_wait_time)
244+
_tries = _tries + 1
245+
return self.request(method, url, _tries=_tries, **kwargs)
246+
247+
raise APIException(
248+
code=error["code"],
249+
message=error["message"],
250+
details=error["details"],
251+
trace_id=trace_id,
252+
)
253+
254+
return payload

hcloud/_exceptions.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,22 @@ class HCloudException(Exception):
1010
class APIException(HCloudException):
1111
"""There was an error while performing an API Request"""
1212

13-
def __init__(self, code: int | str, message: str | None, details: Any):
14-
super().__init__(code if message is None and isinstance(code, str) else message)
13+
def __init__(
14+
self,
15+
code: int | str,
16+
message: str,
17+
details: Any,
18+
*,
19+
trace_id: str | None = None,
20+
):
21+
extras = [str(code)]
22+
if trace_id is not None:
23+
extras.append(trace_id)
24+
25+
error = f"{message} ({', '.join(extras)})"
26+
27+
super().__init__(error)
1528
self.code = code
1629
self.message = message
1730
self.details = details
31+
self.trace_id = trace_id

tests/unit/test_client.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,31 @@ def test_request_fails(self, client, fail_response):
102102
assert error.message == "invalid input in field 'broken_field': is too long"
103103
assert error.details["fields"][0]["name"] == "broken_field"
104104

105+
def test_request_fails_trace_id(self, client, response):
106+
response.headers["X-Correlation-Id"] = "67ed842dc8bc8673"
107+
response.status_code = 409
108+
response._content = json.dumps(
109+
{
110+
"error": {
111+
"code": "conflict",
112+
"message": "some conflict",
113+
"details": None,
114+
}
115+
}
116+
).encode("utf-8")
117+
118+
client._requests_session.request.return_value = response
119+
with pytest.raises(APIException) as exception_info:
120+
client.request(
121+
"POST", "http://url.com", params={"argument": "value"}, timeout=2
122+
)
123+
error = exception_info.value
124+
assert error.code == "conflict"
125+
assert error.message == "some conflict"
126+
assert error.details is None
127+
assert error.trace_id == "67ed842dc8bc8673"
128+
assert str(error) == "some conflict (conflict, 67ed842dc8bc8673)"
129+
105130
def test_request_500(self, client, fail_response):
106131
fail_response.status_code = 500
107132
fail_response.reason = "Internal Server Error"
@@ -153,7 +178,7 @@ def test_request_500_empty_content(self, client, fail_response):
153178
assert error.code == 500
154179
assert error.message == "Internal Server Error"
155180
assert error.details["content"] == ""
156-
assert str(error) == "Internal Server Error"
181+
assert str(error) == "Internal Server Error (500)"
157182

158183
def test_request_limit(self, client, rate_limit_response):
159184
client._retry_wait_time = 0

0 commit comments

Comments
 (0)