diff --git a/mpt_api_client/exceptions.py b/mpt_api_client/exceptions.py new file mode 100644 index 0000000..4a17c1a --- /dev/null +++ b/mpt_api_client/exceptions.py @@ -0,0 +1,67 @@ +import json +from typing import override + +from httpx import HTTPStatusError + + +class MPTError(Exception): + """Represents a generic MPT error.""" + + +class MPTHttpError(MPTError): + """Represents an HTTP error.""" + + def __init__(self, status_code: int, text: str): + self.status_code = status_code + self.text = text + super().__init__(f"{self.status_code} - {self.text}") + + +class MPTAPIError(MPTHttpError): + """Represents an API error.""" + + def __init__(self, status_code: int, payload: dict[str, str]): + super().__init__(status_code, json.dumps(payload)) + self.payload = payload + self.status: str | None = payload.get("status") + self.title: str | None = payload.get("title") + self.detail: str | None = payload.get("detail") + self.trace_id: str | None = payload.get("traceId") + self.errors: str | None = payload.get("errors") + + @override + def __str__(self) -> str: + base = f"{self.status} {self.title} - {self.detail} ({self.trace_id})" + + if self.errors: + return f"{base}\n{json.dumps(self.errors, indent=2)}" + return base + + @override + def __repr__(self) -> str: + return str(self.payload) + + +def transform_http_status_exception(http_status_exception: HTTPStatusError) -> MPTError: + """Transforms httpx exceptions into MPT exceptions. + + Attempts to extract API related information from HTTPStatusError and + raises MPTAPIError or MPTHttpError. + + Args: + http_status_exception: Native httpx exception + + Returns: + MPTError + """ + try: + return MPTAPIError( + status_code=http_status_exception.response.status_code, + payload=http_status_exception.response.json(), + ) + except json.JSONDecodeError: + payload = http_status_exception.response.content.decode() + return MPTHttpError( + status_code=http_status_exception.response.status_code, + text=payload, + ) diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index a9b8247..7ce9053 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -1,6 +1,21 @@ import os +from typing import Any, override -from httpx import AsyncClient, AsyncHTTPTransport +from httpx import URL, AsyncClient, AsyncHTTPTransport, HTTPError, HTTPStatusError, Response +from httpx._client import USE_CLIENT_DEFAULT, UseClientDefault # noqa: PLC2701 +from httpx._types import ( # noqa: WPS235 + AuthTypes, + CookieTypes, + HeaderTypes, + QueryParamTypes, + RequestContent, + RequestData, + RequestExtensions, + RequestFiles, + TimeoutTypes, +) + +from mpt_api_client.exceptions import MPTError, transform_http_status_exception class AsyncHTTPClient(AsyncClient): @@ -12,7 +27,7 @@ def __init__( base_url: str | None = None, api_token: str | None = None, timeout: float = 5.0, - retries: int = 0, + retries: int = 5, ): api_token = api_token or os.getenv("MPT_TOKEN") if not api_token: @@ -40,3 +55,43 @@ def __init__( timeout=timeout, transport=AsyncHTTPTransport(retries=retries), ) + + @override + async def request( # noqa: WPS211 + self, + method: str, + url: URL | str, + *, + content: RequestContent | None = None, # noqa: WPS110 + data: RequestData | None = None, # noqa: WPS110 + files: RequestFiles | None = None, + json: Any | None = None, + params: QueryParamTypes | None = None, # noqa: WPS110 + headers: HeaderTypes | None = None, + cookies: CookieTypes | None = None, + auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, + follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, + timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, + extensions: RequestExtensions | None = None, + ) -> Response: + try: + response = await super().request( + method, + url, + content=content, + data=data, + files=files, + json=json, + params=params, + headers=headers, + cookies=cookies, + auth=auth, + ) + except HTTPError as err: + raise MPTError(f"HTTP Error: {err}") from err + + try: + response.raise_for_status() + except HTTPStatusError as http_status_exception: + raise transform_http_status_exception(http_status_exception) from http_status_exception + return response diff --git a/mpt_api_client/http/async_service.py b/mpt_api_client/http/async_service.py index 3788673..9daaddc 100644 --- a/mpt_api_client/http/async_service.py +++ b/mpt_api_client/http/async_service.py @@ -101,10 +101,7 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> ht HTTPStatusError: if the response status code is not 200. """ pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - response = await self.http_client.get(self.build_url(pagination_params)) - response.raise_for_status() - - return response + return await self.http_client.get(self.build_url(pagination_params)) async def _resource_do_request( # noqa: WPS211 self, @@ -133,11 +130,9 @@ async def _resource_do_request( # noqa: WPS211 """ resource_url = urljoin(f"{self.endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url - response = await self.http_client.request( + return await self.http_client.request( method, url, json=json, params=query_params, headers=headers ) - response.raise_for_status() - return response async def _resource_action( self, diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 5e587b1..6192189 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -1,6 +1,32 @@ import os +from typing import Any, override -from httpx import Client, HTTPTransport +from httpx import ( + URL, + USE_CLIENT_DEFAULT, + Client, + HTTPError, + HTTPStatusError, + HTTPTransport, + Response, +) +from httpx._client import UseClientDefault +from httpx._types import ( + AuthTypes, + CookieTypes, + HeaderTypes, + QueryParamTypes, + RequestContent, + RequestData, + RequestExtensions, + TimeoutTypes, +) +from respx.types import RequestFiles + +from mpt_api_client.exceptions import ( + MPTError, + transform_http_status_exception, +) class HTTPClient(Client): @@ -12,7 +38,7 @@ def __init__( base_url: str | None = None, api_token: str | None = None, timeout: float = 5.0, - retries: int = 0, + retries: int = 5, ): api_token = api_token or os.getenv("MPT_TOKEN") if not api_token: @@ -40,3 +66,43 @@ def __init__( timeout=timeout, transport=HTTPTransport(retries=retries), ) + + @override + def request( # noqa: WPS211 + self, + method: str, + url: URL | str, + *, + content: RequestContent | None = None, # noqa: WPS110 + data: RequestData | None = None, # noqa: WPS110 + files: RequestFiles | None = None, + json: Any | None = None, + params: QueryParamTypes | None = None, # noqa: WPS110 + headers: HeaderTypes | None = None, + cookies: CookieTypes | None = None, + auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, + follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, + timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, + extensions: RequestExtensions | None = None, + ) -> Response: + try: + response = super().request( + method, + url, + content=content, + data=data, + files=files, + json=json, + params=params, + headers=headers, + cookies=cookies, + auth=auth, + ) + except HTTPError as err: + raise MPTError(f"HTTP Error: {err}") from err + + try: + response.raise_for_status() + except HTTPStatusError as http_status_exception: + raise transform_http_status_exception(http_status_exception) from http_status_exception + return response diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py index 11f655d..fbbb30e 100644 --- a/mpt_api_client/http/mixins.py +++ b/mpt_api_client/http/mixins.py @@ -23,7 +23,6 @@ def create(self, resource_data: ResourceData) -> Model: New resource created. """ response = self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined] - response.raise_for_status() return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -37,8 +36,7 @@ def delete(self, resource_id: str) -> None: Args: resource_id: Resource ID. """ - response = self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined] - response.raise_for_status() + self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined] class UpdateMixin[Model]: @@ -87,7 +85,7 @@ def create( ) response = self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined] - response.raise_for_status() + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] def download(self, resource_id: str) -> FileModel: @@ -115,7 +113,6 @@ async def create(self, resource_data: ResourceData) -> Model: New resource created. """ response = await self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined] - response.raise_for_status() return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -130,8 +127,7 @@ async def delete(self, resource_id: str) -> None: resource_id: Resource ID. """ url = urljoin(f"{self.endpoint}/", resource_id) # type: ignore[attr-defined] - response = await self.http_client.delete(url) # type: ignore[attr-defined] - response.raise_for_status() + await self.http_client.delete(url) # type: ignore[attr-defined] class AsyncUpdateMixin[Model]: @@ -180,7 +176,7 @@ async def create( ) response = await self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined] - response.raise_for_status() + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] async def download(self, resource_id: str) -> FileModel: diff --git a/mpt_api_client/http/service.py b/mpt_api_client/http/service.py index 7dfe2c1..74c9d4f 100644 --- a/mpt_api_client/http/service.py +++ b/mpt_api_client/http/service.py @@ -101,10 +101,7 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re HTTPStatusError: if the response status code is not 200. """ pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - response = self.http_client.get(self.build_url(pagination_params)) - response.raise_for_status() - - return response + return self.http_client.get(self.build_url(pagination_params)) def _resource_do_request( # noqa: WPS211 self, @@ -133,11 +130,9 @@ def _resource_do_request( # noqa: WPS211 """ resource_url = urljoin(f"{self.endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url - response = self.http_client.request( + return self.http_client.request( method, url, json=json, params=query_params, headers=headers ) - response.raise_for_status() - return response def _resource_action( self, diff --git a/mpt_api_client/resources/notifications/accounts.py b/mpt_api_client/resources/notifications/accounts.py index d26a874..475dbee 100644 --- a/mpt_api_client/resources/notifications/accounts.py +++ b/mpt_api_client/resources/notifications/accounts.py @@ -1,10 +1,11 @@ from typing import override +from mpt_api_client.exceptions import MPTError from mpt_api_client.http import AsyncService, Service from mpt_api_client.models import Model -class MethodNotAllowedError(Exception): +class MethodNotAllowedError(MPTError): """Method not allowed error.""" diff --git a/mpt_api_client/resources/notifications/batches.py b/mpt_api_client/resources/notifications/batches.py index 2eb76a2..8cf21e3 100644 --- a/mpt_api_client/resources/notifications/batches.py +++ b/mpt_api_client/resources/notifications/batches.py @@ -49,7 +49,6 @@ def create( ) response = self.http_client.post(self.endpoint, files=files) - response.raise_for_status() return self._model_class.from_response(response) 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: FileModel containing the attachment. """ response = self.http_client.get(f"{self.endpoint}/{batch_id}/attachments/{attachment_id}") - response.raise_for_status() + return FileModel(response) @@ -99,7 +98,6 @@ async def create( ) response = await self.http_client.post(self.endpoint, files=files) - response.raise_for_status() return self._model_class.from_response(response) 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 response = await self.http_client.get( f"{self.endpoint}/{batch_id}/attachments/{attachment_id}" ) - response.raise_for_status() return FileModel(response) diff --git a/tests/http/test_async_client.py b/tests/http/test_async_client.py index f67f975..dfc3134 100644 --- a/tests/http/test_async_client.py +++ b/tests/http/test_async_client.py @@ -2,6 +2,7 @@ import respx from httpx import ConnectTimeout, Response, codes +from mpt_api_client.exceptions import MPTError from mpt_api_client.http.async_client import AsyncHTTPClient from tests.conftest import API_TOKEN, API_URL @@ -51,7 +52,7 @@ async def test_async_http_call_success(async_http_client): async def test_async_http_call_failure(async_http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) - with pytest.raises(ConnectTimeout): + with pytest.raises(MPTError, match="HTTP Error: Mock Timeout"): await async_http_client.get("/timeout") assert timeout_route.called diff --git a/tests/http/test_async_service.py b/tests/http/test_async_service.py index e747805..866dcb1 100644 --- a/tests/http/test_async_service.py +++ b/tests/http/test_async_service.py @@ -2,6 +2,7 @@ import pytest import respx +from mpt_api_client.exceptions import MPTAPIError from tests.conftest import DummyModel from tests.http.conftest import AsyncDummyService @@ -256,3 +257,15 @@ async def test_async_get(async_dummy_service): assert accept_header in request.headers.raw assert isinstance(resource, DummyModel) assert resource.to_dict() == resource_data + + +async def test_sync_iterate_handles_api_errors(async_dummy_service): + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=httpx.Response( + httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} + ) + ) + + with pytest.raises(MPTAPIError): + [resource async for resource in async_dummy_service.iterate()] diff --git a/tests/http/test_client.py b/tests/http/test_client.py index 8e1a31d..b47c663 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -2,6 +2,7 @@ import respx from httpx import ConnectTimeout, Response, codes +from mpt_api_client.exceptions import MPTError from mpt_api_client.http.client import HTTPClient from tests.conftest import API_TOKEN, API_URL @@ -51,7 +52,7 @@ def test_http_call_success(http_client): def test_http_call_failure(http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) - with pytest.raises(ConnectTimeout): + with pytest.raises(MPTError, match="HTTP Error: Mock Timeout"): http_client.get("/timeout") assert timeout_route.called diff --git a/tests/http/test_service.py b/tests/http/test_service.py index e83cbb6..f794d44 100644 --- a/tests/http/test_service.py +++ b/tests/http/test_service.py @@ -2,6 +2,7 @@ import pytest import respx +from mpt_api_client.exceptions import MPTAPIError from tests.conftest import DummyModel from tests.http.conftest import DummyService @@ -263,5 +264,5 @@ def test_sync_iterate_handles_api_errors(dummy_service): iterator = dummy_service.iterate() - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(MPTAPIError): list(iterator) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..db3a794 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,91 @@ +import json + +from httpx import HTTPStatusError, Request, Response + +from mpt_api_client.exceptions import ( + MPTAPIError, + MPTHttpError, + transform_http_status_exception, +) + + +def test_http_error(): + exception = MPTHttpError(status_code=400, text="Content") + + assert exception.status_code == 400 + assert exception.text == "Content" + + +def test_api_error(): # noqa: WPS218 + payload = { + "status": "400", + "title": "Bad Request", + "detail": "Invalid input", + "traceId": "abc123", + "errors": "Some error details", + } + exception = MPTAPIError(status_code=400, payload=payload) + + assert exception.status_code == 400 + assert exception.payload == payload + assert exception.status == "400" + assert exception.title == "Bad Request" + assert exception.detail == "Invalid input" + assert exception.trace_id == "abc123" + assert exception.errors == "Some error details" + + +def test_api_error_str_and_repr(): + payload = { + "status": "400", + "title": "Bad Request", + "detail": "Invalid input", + "traceId": "abc123", + "errors": "Some error details", + } + exception = MPTAPIError(status_code=400, payload=payload) + + assert str(exception) == '400 Bad Request - Invalid input (abc123)\n"Some error details"' + assert repr(exception) == ( + "{'status': '400', 'title': 'Bad Request', 'detail': 'Invalid input', " + "'traceId': 'abc123', 'errors': 'Some error details'}" + ) + + +def test_transform_http_status_exception(): + payload = { + "status": "400", + "title": "Bad Request", + "detail": "Invalid input", + "traceId": "abc123", + "errors": "Some error details", + } + response = Response( + status_code=400, + request=Request("GET", "http://test"), + content=json.dumps(payload).encode(), + headers={"content-type": "application/json"}, + ) + exc = HTTPStatusError("error", request=response.request, response=response) + + err = transform_http_status_exception(exc) + + assert isinstance(err, MPTAPIError) + assert err.status_code == 400 + assert err.payload == payload + + +def test_transform_http_status_exception_json(): + response = Response( + status_code=500, + request=Request("GET", "http://test"), + content=b"Internal Server Error", + headers={"content-type": "text/plain"}, + ) + exc = HTTPStatusError("error", request=response.request, response=response) + + err = transform_http_status_exception(exc) + + assert isinstance(err, MPTHttpError) + assert err.status_code == 500 + assert err.text == "Internal Server Error"