Skip to content

Commit 9a4ec39

Browse files
SIMPLE-6345 refactored API error handling for better tracebacks (#98)
1 parent 2b64d5a commit 9a4ec39

File tree

3 files changed

+54
-23
lines changed

3 files changed

+54
-23
lines changed

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import httpx
2525
import pytest
2626

27+
from virl2_client.models import authentication
2728
from virl2_client.virl2_client import ClientLibrary
2829

2930
CURRENT_VERSION = ClientLibrary.VERSION.version_str
@@ -65,7 +66,7 @@ def client_library_server_3_19_0():
6566

6667
@pytest.fixture
6768
def mocked_session():
68-
with patch.object(httpx, "Client", autospec=True) as session:
69+
with patch.object(authentication, "CustomClient", autospec=True) as session:
6970
yield session
7071

7172

virl2_client/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# See the License for the specific language governing permissions and
1818
# limitations under the License.
1919
#
20+
import httpx
2021

2122

2223
class VirlException(Exception):
@@ -97,3 +98,7 @@ class InvalidMacAddressBlock(VirlException):
9798

9899
class ControllerNotFound(VirlException):
99100
message = "Controller not found"
101+
102+
103+
class APIError(VirlException, httpx.HTTPStatusError):
104+
pass

virl2_client/models/authentication.py

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828
import httpx
2929

30+
from ..exceptions import APIError
31+
3032
_LOGGER = logging.getLogger(__name__)
3133

3234
if TYPE_CHECKING:
@@ -36,6 +38,19 @@
3638
_AUTH_URL = "authenticate"
3739

3840

41+
def raise_for_status(response: httpx.Response):
42+
"""
43+
https://github.com/encode/httpx/discussions/2224#discussioncomment-2732372
44+
45+
When raising for status from certain places, if response is unread, the stream is
46+
automatically closed, and we then cannot read the response in later error handling.
47+
We thus need to check if the response is 4/500 and read it preemptively if so.
48+
"""
49+
if response.status_code // 100 in (4, 5):
50+
response.read()
51+
response.raise_for_status()
52+
53+
3954
class TokenAuth(httpx.Auth):
4055
"""
4156
Token-based authentication for an httpx session.
@@ -80,7 +95,7 @@ def token(self) -> str | None:
8095
json=data,
8196
auth=None, # type: ignore
8297
) # auth=None works but is missing from .post's type hint
83-
response_raise(response)
98+
raise_for_status(response)
8499
self._token = response.json()
85100
return self._token
86101

@@ -111,7 +126,7 @@ def auth_flow(
111126
request.headers["Authorization"] = f"Bearer {self.token}"
112127
response = yield request
113128

114-
response_raise(response)
129+
raise_for_status(response)
115130

116131
def logout(self, clear_all_sessions=False) -> bool:
117132
"""
@@ -131,29 +146,39 @@ def auth_flow(
131146
self, request: httpx.Request
132147
) -> Generator[httpx.Request, httpx.Response, None]:
133148
response = yield request
134-
response_raise(response)
149+
raise_for_status(response)
135150

136151

137-
def response_raise(response: httpx.Response) -> None:
138-
"""
139-
Raise an exception if the response has an HTTP status error.
140-
Replace the useless link to httpstatuses.com with error description.
152+
class CustomClient(httpx.Client):
153+
_ERROR_PREFIX = {4: "Client error - ", 5: "Server error - "}
141154

142-
:param response: The response object to check.
143-
:raises httpx.HTTPStatusError: If the response has an HTTP status error.
144-
"""
145-
try:
146-
response.raise_for_status()
147-
except httpx.HTTPStatusError as error:
148-
error.response.read()
155+
def __init__(self, *args, **kwargs):
156+
super().__init__(*args, **kwargs)
157+
self._original_request = self.request
158+
self.request = self._request
159+
160+
def _request(self, *args, **kwargs):
161+
"""
162+
httpx.Client.request modified to raise an exception if the response
163+
has an HTTP status error and replace the useless link
164+
to httpstatuses.com with error description.
165+
166+
:raises APIError: If the response has an HTTP status error.
167+
"""
149168
try:
150-
error_msg = json.loads(error.response.text)["description"]
151-
except (json.JSONDecodeError, IndexError, TypeError):
152-
error_msg = error.response.text
153-
error_text: str = error.args[0].split("\n")[0]
154-
error_text += "\n" + error_msg
155-
error.args = (error_text,)
156-
raise error
169+
return self._original_request(*args, **kwargs)
170+
except httpx.HTTPStatusError as error:
171+
try:
172+
error_detail = json.loads(error.response.text)["description"]
173+
except (json.JSONDecodeError, IndexError, TypeError):
174+
error_detail = error.response.text
175+
prefix = self._ERROR_PREFIX.get(error.response.status_code // 100, "")
176+
api_error = APIError(
177+
f"{prefix}{error_detail or error}",
178+
request=error.request,
179+
response=error.response,
180+
)
181+
raise api_error from None
157182

158183

159184
def make_session(base_url: str, ssl_verify: bool = True) -> httpx.Client:
@@ -168,7 +193,7 @@ def make_session(base_url: str, ssl_verify: bool = True) -> httpx.Client:
168193
:param ssl_verify: Whether to perform SSL verification.
169194
:returns: The created httpx Client object.
170195
"""
171-
return httpx.Client(
196+
return CustomClient(
172197
base_url=base_url,
173198
verify=ssl_verify,
174199
auth=BlankAuth(),

0 commit comments

Comments
 (0)