Skip to content

Commit e1346f4

Browse files
authored
MPT-14266 Add custom exceptions (#92)
2 parents 47eb5c4 + 7dae891 commit e1346f4

File tree

13 files changed

+313
-34
lines changed

13 files changed

+313
-34
lines changed

mpt_api_client/exceptions.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import json
2+
from typing import override
3+
4+
from httpx import HTTPStatusError
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, text: str):
15+
self.status_code = status_code
16+
self.text = text
17+
super().__init__(f"{self.status_code} - {self.text}")
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 transform_http_status_exception(http_status_exception: HTTPStatusError) -> MPTError:
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+
Returns:
55+
MPTError
56+
"""
57+
try:
58+
return MPTAPIError(
59+
status_code=http_status_exception.response.status_code,
60+
payload=http_status_exception.response.json(),
61+
)
62+
except json.JSONDecodeError:
63+
payload = http_status_exception.response.content.decode()
64+
return MPTHttpError(
65+
status_code=http_status_exception.response.status_code,
66+
text=payload,
67+
)

mpt_api_client/http/async_client.py

Lines changed: 57 additions & 2 deletions
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, transform_http_status_exception
419

520

621
class AsyncHTTPClient(AsyncClient):
@@ -12,7 +27,7 @@ def __init__(
1227
base_url: str | None = None,
1328
api_token: str | None = None,
1429
timeout: float = 5.0,
15-
retries: int = 0,
30+
retries: int = 5,
1631
):
1732
api_token = api_token or os.getenv("MPT_TOKEN")
1833
if not api_token:
@@ -40,3 +55,43 @@ def __init__(
4055
timeout=timeout,
4156
transport=AsyncHTTPTransport(retries=retries),
4257
)
58+
59+
@override
60+
async def request( # noqa: WPS211
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:
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+
except HTTPError as err:
91+
raise MPTError(f"HTTP Error: {err}") from err
92+
93+
try:
94+
response.raise_for_status()
95+
except HTTPStatusError as http_status_exception:
96+
raise transform_http_status_exception(http_status_exception) from http_status_exception
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: 68 additions & 2 deletions
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+
transform_http_status_exception,
29+
)
430

531

632
class HTTPClient(Client):
@@ -12,7 +38,7 @@ def __init__(
1238
base_url: str | None = None,
1339
api_token: str | None = None,
1440
timeout: float = 5.0,
15-
retries: int = 0,
41+
retries: int = 5,
1642
):
1743
api_token = api_token or os.getenv("MPT_TOKEN")
1844
if not api_token:
@@ -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:
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+
except HTTPError as err:
102+
raise MPTError(f"HTTP Error: {err}") from err
103+
104+
try:
105+
response.raise_for_status()
106+
except HTTPStatusError as http_status_exception:
107+
raise transform_http_status_exception(http_status_exception) from http_status_exception
108+
return response

mpt_api_client/http/mixins.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ 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()
2726

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

@@ -37,8 +36,7 @@ def delete(self, resource_id: str) -> None:
3736
Args:
3837
resource_id: Resource ID.
3938
"""
40-
response = self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined]
41-
response.raise_for_status()
39+
self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined]
4240

4341

4442
class UpdateMixin[Model]:
@@ -87,7 +85,7 @@ def create(
8785
)
8886

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

9391
def download(self, resource_id: str) -> FileModel:
@@ -115,7 +113,6 @@ async def create(self, resource_data: ResourceData) -> Model:
115113
New resource created.
116114
"""
117115
response = await self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined]
118-
response.raise_for_status()
119116

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

@@ -130,8 +127,7 @@ async def delete(self, resource_id: str) -> None:
130127
resource_id: Resource ID.
131128
"""
132129
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()
130+
await self.http_client.delete(url) # type: ignore[attr-defined]
135131

136132

137133
class AsyncUpdateMixin[Model]:
@@ -180,7 +176,7 @@ async def create(
180176
)
181177

182178
response = await self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined]
183-
response.raise_for_status()
179+
184180
return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return]
185181

186182
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import respx
33
from httpx import ConnectTimeout, Response, codes
44

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

@@ -51,7 +52,7 @@ async def test_async_http_call_success(async_http_client):
5152
async def test_async_http_call_failure(async_http_client):
5253
timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout"))
5354

54-
with pytest.raises(ConnectTimeout):
55+
with pytest.raises(MPTError, match="HTTP Error: Mock Timeout"):
5556
await async_http_client.get("/timeout")
5657

5758
assert timeout_route.called

0 commit comments

Comments
 (0)