Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add trace_id to API exceptions #404

Merged
merged 2 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 34 additions & 31 deletions hcloud/_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import time
from typing import NoReturn, Protocol
from typing import Protocol

import requests

Expand Down Expand Up @@ -190,20 +190,6 @@ def _get_headers(self) -> dict:
}
return headers

def _raise_exception_from_response(self, response: requests.Response) -> NoReturn:
raise APIException(
code=response.status_code,
message=response.reason,
details={"content": response.content},
)

def _raise_exception_from_content(self, content: dict) -> NoReturn:
raise APIException(
code=content["error"]["code"],
message=content["error"]["message"],
details=content["error"]["details"],
)

def request( # type: ignore[no-untyped-def]
self,
method: str,
Expand All @@ -229,23 +215,40 @@ def request( # type: ignore[no-untyped-def]
**kwargs,
)

content = {}
trace_id = response.headers.get("X-Correlation-Id")
payload = {}
try:
if len(response.content) > 0:
content = response.json()
except (TypeError, ValueError):
self._raise_exception_from_response(response)
payload = response.json()
except (TypeError, ValueError) as exc:
raise APIException(
code=response.status_code,
message=response.reason,
details={"content": response.content},
trace_id=trace_id,
) from exc

if not response.ok:
if content:
assert isinstance(content, dict)
if content["error"]["code"] == "rate_limit_exceeded" and _tries < 5:
time.sleep(_tries * self._retry_wait_time)
_tries = _tries + 1
return self.request(method, url, _tries=_tries, **kwargs)

self._raise_exception_from_content(content)
else:
self._raise_exception_from_response(response)

return content
if not payload or "error" not in payload:
raise APIException(
code=response.status_code,
message=response.reason,
details={"content": response.content},
trace_id=trace_id,
)

error: dict = payload["error"]

if error["code"] == "rate_limit_exceeded" and _tries < 5:
time.sleep(_tries * self._retry_wait_time)
_tries = _tries + 1
return self.request(method, url, _tries=_tries, **kwargs)

raise APIException(
code=error["code"],
message=error["message"],
details=error["details"],
trace_id=trace_id,
)

return payload
18 changes: 16 additions & 2 deletions hcloud/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,22 @@ class HCloudException(Exception):
class APIException(HCloudException):
"""There was an error while performing an API Request"""

def __init__(self, code: int | str, message: str | None, details: Any):
super().__init__(code if message is None and isinstance(code, str) else message)
def __init__(
self,
code: int | str,
message: str,
details: Any,
*,
trace_id: str | None = None,
):
extras = [str(code)]
if trace_id is not None:
extras.append(trace_id)

error = f"{message} ({', '.join(extras)})"

super().__init__(error)
self.code = code
self.message = message
self.details = details
self.trace_id = trace_id
27 changes: 26 additions & 1 deletion tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,31 @@ def test_request_fails(self, client, fail_response):
assert error.message == "invalid input in field 'broken_field': is too long"
assert error.details["fields"][0]["name"] == "broken_field"

def test_request_fails_trace_id(self, client, response):
response.headers["X-Correlation-Id"] = "67ed842dc8bc8673"
response.status_code = 409
response._content = json.dumps(
{
"error": {
"code": "conflict",
"message": "some conflict",
"details": None,
}
}
).encode("utf-8")

client._requests_session.request.return_value = response
with pytest.raises(APIException) as exception_info:
client.request(
"POST", "http://url.com", params={"argument": "value"}, timeout=2
)
error = exception_info.value
assert error.code == "conflict"
assert error.message == "some conflict"
assert error.details is None
assert error.trace_id == "67ed842dc8bc8673"
assert str(error) == "some conflict (conflict, 67ed842dc8bc8673)"

def test_request_500(self, client, fail_response):
fail_response.status_code = 500
fail_response.reason = "Internal Server Error"
Expand Down Expand Up @@ -153,7 +178,7 @@ def test_request_500_empty_content(self, client, fail_response):
assert error.code == 500
assert error.message == "Internal Server Error"
assert error.details["content"] == ""
assert str(error) == "Internal Server Error"
assert str(error) == "Internal Server Error (500)"

def test_request_limit(self, client, rate_limit_response):
client._retry_wait_time = 0
Expand Down