Skip to content

Commit bc70fb0

Browse files
committed
MPT-14266 Add custom exceptions (WIP)
1 parent 47eb5c4 commit bc70fb0

12 files changed

Lines changed: 242 additions & 31 deletions

File tree

mpt_api_client/exceptions.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import json
2+
from typing import override
3+
4+
from httpx import HTTPStatusError, Response
5+
6+
7+
class MPTError(Exception):
8+
"""Represents a generic MPT error."""
9+
10+
11+
class MPTHttpError(MPTError):
12+
"""Represents an HTTP error."""
13+
14+
def __init__(self, status_code: int, content: str): # noqa: WPS110
15+
self.status_code = status_code
16+
self.content = content # noqa: WPS110
17+
super().__init__(f"{self.status_code} - {self.content}")
18+
19+
20+
class MPTAPIError(MPTHttpError):
21+
"""Represents an API error."""
22+
23+
def __init__(self, status_code: int, payload: dict[str, str]):
24+
super().__init__(status_code, json.dumps(payload))
25+
self.payload = payload
26+
self.status: str | None = payload.get("status")
27+
self.title: str | None = payload.get("title")
28+
self.detail: str | None = payload.get("detail")
29+
self.trace_id: str | None = payload.get("traceId")
30+
self.errors: str | None = payload.get("errors")
31+
32+
@override
33+
def __str__(self) -> str:
34+
base = f"{self.status} {self.title} - {self.detail} ({self.trace_id})"
35+
36+
if self.errors:
37+
return f"{base}\n{json.dumps(self.errors, indent=2)}"
38+
return base
39+
40+
@override
41+
def __repr__(self) -> str:
42+
return str(self.payload)
43+
44+
45+
def handle_http_status_exception(http_status_exception: HTTPStatusError) -> None:
46+
"""Transforms httpx exceptions into MPT exceptions.
47+
48+
Attempts to extract API related information from HTTPStatusError and
49+
raises MPTAPIError or MPTHttpError.
50+
51+
Args:
52+
http_status_exception: Native httpx exception
53+
54+
Raises:
55+
MPTAPIError
56+
MPTHttpError
57+
"""
58+
try: # noqa: WPS229
59+
payload = http_status_exception.response.json()
60+
raise MPTAPIError(
61+
status_code=http_status_exception.response.status_code,
62+
payload=payload,
63+
) from http_status_exception
64+
except json.JSONDecodeError:
65+
payload = http_status_exception.response.content.decode()
66+
raise MPTHttpError(
67+
status_code=http_status_exception.response.status_code,
68+
content=payload,
69+
) from http_status_exception
70+
71+
72+
def raise_for_status(response: Response) -> None:
73+
"""Raise an exception for the given response."""
74+
try:
75+
response.raise_for_status()
76+
except HTTPStatusError as ex:
77+
handle_http_status_exception(ex)

mpt_api_client/http/async_client.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import os
2+
from typing import Any, override
23

3-
from httpx import AsyncClient, AsyncHTTPTransport
4+
from httpx import URL, AsyncClient, AsyncHTTPTransport, HTTPError, HTTPStatusError, Response
5+
from httpx._client import USE_CLIENT_DEFAULT, UseClientDefault # noqa: PLC2701
6+
from httpx._types import ( # noqa: WPS235
7+
AuthTypes,
8+
CookieTypes,
9+
HeaderTypes,
10+
QueryParamTypes,
11+
RequestContent,
12+
RequestData,
13+
RequestExtensions,
14+
RequestFiles,
15+
TimeoutTypes,
16+
)
17+
18+
from mpt_api_client.exceptions import MPTError, handle_http_status_exception
419

520

621
class AsyncHTTPClient(AsyncClient):
@@ -40,3 +55,43 @@ def __init__(
4055
timeout=timeout,
4156
transport=AsyncHTTPTransport(retries=retries),
4257
)
58+
59+
@override
60+
async def request( # noqa: WPS211 type:ignore[return]
61+
self,
62+
method: str,
63+
url: URL | str,
64+
*,
65+
content: RequestContent | None = None, # noqa: WPS110
66+
data: RequestData | None = None, # noqa: WPS110
67+
files: RequestFiles | None = None,
68+
json: Any | None = None,
69+
params: QueryParamTypes | None = None, # noqa: WPS110
70+
headers: HeaderTypes | None = None,
71+
cookies: CookieTypes | None = None,
72+
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
73+
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
74+
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
75+
extensions: RequestExtensions | None = None,
76+
) -> Response:
77+
try: # noqa: WPS229
78+
response = await super().request(
79+
method,
80+
url,
81+
content=content,
82+
data=data,
83+
files=files,
84+
json=json,
85+
params=params,
86+
headers=headers,
87+
cookies=cookies,
88+
auth=auth,
89+
)
90+
response.raise_for_status()
91+
except HTTPStatusError as http_status_exception:
92+
handle_http_status_exception(http_status_exception)
93+
except HTTPError as err:
94+
http_error = MPTError()
95+
raise http_error from err
96+
else:
97+
return response

mpt_api_client/http/async_service.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,7 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> ht
101101
HTTPStatusError: if the response status code is not 200.
102102
"""
103103
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
104-
response = await self.http_client.get(self.build_url(pagination_params))
105-
response.raise_for_status()
106-
107-
return response
104+
return await self.http_client.get(self.build_url(pagination_params))
108105

109106
async def _resource_do_request( # noqa: WPS211
110107
self,
@@ -133,11 +130,9 @@ async def _resource_do_request( # noqa: WPS211
133130
"""
134131
resource_url = urljoin(f"{self.endpoint}/", resource_id)
135132
url = urljoin(f"{resource_url}/", action) if action else resource_url
136-
response = await self.http_client.request(
133+
return await self.http_client.request(
137134
method, url, json=json, params=query_params, headers=headers
138135
)
139-
response.raise_for_status()
140-
return response
141136

142137
async def _resource_action(
143138
self,

mpt_api_client/http/client.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
import os
2+
from typing import Any, override
23

3-
from httpx import Client, HTTPTransport
4+
from httpx import (
5+
URL,
6+
USE_CLIENT_DEFAULT,
7+
Client,
8+
HTTPError,
9+
HTTPStatusError,
10+
HTTPTransport,
11+
Response,
12+
)
13+
from httpx._client import UseClientDefault
14+
from httpx._types import (
15+
AuthTypes,
16+
CookieTypes,
17+
HeaderTypes,
18+
QueryParamTypes,
19+
RequestContent,
20+
RequestData,
21+
RequestExtensions,
22+
TimeoutTypes,
23+
)
24+
from respx.types import RequestFiles
25+
26+
from mpt_api_client.exceptions import (
27+
MPTError,
28+
handle_http_status_exception,
29+
)
430

531

632
class HTTPClient(Client):
@@ -40,3 +66,43 @@ def __init__(
4066
timeout=timeout,
4167
transport=HTTPTransport(retries=retries),
4268
)
69+
70+
@override
71+
def request( # noqa: WPS211
72+
self,
73+
method: str,
74+
url: URL | str,
75+
*,
76+
content: RequestContent | None = None, # noqa: WPS110
77+
data: RequestData | None = None, # noqa: WPS110
78+
files: RequestFiles | None = None,
79+
json: Any | None = None,
80+
params: QueryParamTypes | None = None, # noqa: WPS110
81+
headers: HeaderTypes | None = None,
82+
cookies: CookieTypes | None = None,
83+
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
84+
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
85+
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
86+
extensions: RequestExtensions | None = None,
87+
) -> Response:
88+
try: # noqa: WPS229
89+
response = super().request(
90+
method,
91+
url,
92+
content=content,
93+
data=data,
94+
files=files,
95+
json=json,
96+
params=params,
97+
headers=headers,
98+
cookies=cookies,
99+
auth=auth,
100+
)
101+
response.raise_for_status()
102+
except HTTPStatusError as http_status_exception:
103+
handle_http_status_exception(http_status_exception)
104+
except HTTPError as err:
105+
http_error = MPTError()
106+
raise http_error from err
107+
else:
108+
return response

mpt_api_client/http/mixins.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def create(self, resource_data: ResourceData) -> Model:
2323
New resource created.
2424
"""
2525
response = self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined]
26-
response.raise_for_status()
26+
self.http_client.raise_for_status(response) # type: ignore[attr-defined]
2727

2828
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
2929

@@ -38,7 +38,7 @@ def delete(self, resource_id: str) -> None:
3838
resource_id: Resource ID.
3939
"""
4040
response = self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined]
41-
response.raise_for_status()
41+
self.http_client.raise_for_status(response) # type: ignore[attr-defined]
4242

4343

4444
class UpdateMixin[Model]:
@@ -87,7 +87,7 @@ def create(
8787
)
8888

8989
response = self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined]
90-
response.raise_for_status()
90+
self.http_client.raise_for_status(response) # type: ignore[attr-defined]
9191
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
9292

9393
def download(self, resource_id: str) -> FileModel:
@@ -115,7 +115,6 @@ async def create(self, resource_data: ResourceData) -> Model:
115115
New resource created.
116116
"""
117117
response = await self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined]
118-
response.raise_for_status()
119118

120119
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
121120

@@ -130,8 +129,7 @@ async def delete(self, resource_id: str) -> None:
130129
resource_id: Resource ID.
131130
"""
132131
url = urljoin(f"{self.endpoint}/", resource_id) # type: ignore[attr-defined]
133-
response = await self.http_client.delete(url) # type: ignore[attr-defined]
134-
response.raise_for_status()
132+
await self.http_client.delete(url) # type: ignore[attr-defined]
135133

136134

137135
class AsyncUpdateMixin[Model]:
@@ -180,7 +178,7 @@ async def create(
180178
)
181179

182180
response = await self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined]
183-
response.raise_for_status()
181+
self.http_client.raise_for_status(response) # type: ignore[attr-defined]
184182
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
185183

186184
async def download(self, resource_id: str) -> FileModel:

mpt_api_client/http/service.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re
101101
HTTPStatusError: if the response status code is not 200.
102102
"""
103103
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
104-
response = self.http_client.get(self.build_url(pagination_params))
105-
response.raise_for_status()
106-
107-
return response
104+
return self.http_client.get(self.build_url(pagination_params))
108105

109106
def _resource_do_request( # noqa: WPS211
110107
self,
@@ -133,11 +130,9 @@ def _resource_do_request( # noqa: WPS211
133130
"""
134131
resource_url = urljoin(f"{self.endpoint}/", resource_id)
135132
url = urljoin(f"{resource_url}/", action) if action else resource_url
136-
response = self.http_client.request(
133+
return self.http_client.request(
137134
method, url, json=json, params=query_params, headers=headers
138135
)
139-
response.raise_for_status()
140-
return response
141136

142137
def _resource_action(
143138
self,

mpt_api_client/resources/notifications/accounts.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from typing import override
22

3+
from mpt_api_client.exceptions import MPTError
34
from mpt_api_client.http import AsyncService, Service
45
from mpt_api_client.models import Model
56

67

7-
class MethodNotAllowedError(Exception):
8+
class MethodNotAllowedError(MPTError):
89
"""Method not allowed error."""
910

1011

mpt_api_client/resources/notifications/batches.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ def create(
4949
)
5050

5151
response = self.http_client.post(self.endpoint, files=files)
52-
response.raise_for_status()
5352
return self._model_class.from_response(response)
5453

5554
def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileModel:
@@ -63,7 +62,7 @@ def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileModel:
6362
FileModel containing the attachment.
6463
"""
6564
response = self.http_client.get(f"{self.endpoint}/{batch_id}/attachments/{attachment_id}")
66-
response.raise_for_status()
65+
6766
return FileModel(response)
6867

6968

@@ -99,7 +98,6 @@ async def create(
9998
)
10099

101100
response = await self.http_client.post(self.endpoint, files=files)
102-
response.raise_for_status()
103101
return self._model_class.from_response(response)
104102

105103
async def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileModel:
@@ -115,5 +113,4 @@ async def get_batch_attachment(self, batch_id: str, attachment_id: str) -> FileM
115113
response = await self.http_client.get(
116114
f"{self.endpoint}/{batch_id}/attachments/{attachment_id}"
117115
)
118-
response.raise_for_status()
119116
return FileModel(response)

tests/http/test_async_client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import pytest
22
import respx
3-
from httpx import ConnectTimeout, Response, codes
3+
from httpx import ConnectTimeout, Request, Response, codes
44

5+
from mpt_api_client.exceptions import MPTHTTPError
56
from mpt_api_client.http.async_client import AsyncHTTPClient
67
from tests.conftest import API_TOKEN, API_URL
78

@@ -55,3 +56,11 @@ async def test_async_http_call_failure(async_http_client):
5556
await async_http_client.get("/timeout")
5657

5758
assert timeout_route.called
59+
60+
61+
def test_raise_for_status(async_http_client):
62+
request = Request("GET", f"{API_URL}/")
63+
response = Response(400, text="response content", request=request)
64+
65+
with pytest.raises(MPTHTTPError, match="400 - response content"):
66+
async_http_client.raise_for_status(response)

0 commit comments

Comments
 (0)