Skip to content
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
67 changes: 67 additions & 0 deletions mpt_api_client/exceptions.py
Original file line number Diff line number Diff line change
@@ -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,
)
59 changes: 57 additions & 2 deletions mpt_api_client/http/async_client.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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
9 changes: 2 additions & 7 deletions mpt_api_client/http/async_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 68 additions & 2 deletions mpt_api_client/http/client.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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
12 changes: 4 additions & 8 deletions mpt_api_client/http/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]

Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 2 additions & 7 deletions mpt_api_client/http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion mpt_api_client/resources/notifications/accounts.py
Original file line number Diff line number Diff line change
@@ -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."""


Expand Down
5 changes: 1 addition & 4 deletions mpt_api_client/resources/notifications/batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)


Expand Down Expand Up @@ -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:
Expand All @@ -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)
3 changes: 2 additions & 1 deletion tests/http/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading