From 018a84d7580b7c3cdd0f7baae09819852b9dcde7 Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Mon, 20 Apr 2026 14:03:59 +0100 Subject: [PATCH 1/3] feat(http): use httpx-retries for retry handling in http clients --- mpt_api_client/http/async_client.py | 20 +++++++------------- mpt_api_client/http/client.py | 25 ++++++++----------------- pyproject.toml | 1 + uv.lock | 18 +++++++++++++++++- 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index acd62401..a11eeb1e 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -1,24 +1,15 @@ import os from typing import Any -from httpx import ( - AsyncClient, - AsyncHTTPTransport, - HTTPError, - HTTPStatusError, -) +from httpx import AsyncClient, HTTPError, HTTPStatusError +from httpx_retries import Retry, RetryTransport from mpt_api_client.constants import APPLICATION_JSON from mpt_api_client.exceptions import MPTError, transform_http_status_exception from mpt_api_client.http.client import json_to_file_payload from mpt_api_client.http.client_utils import get_query_params, validate_base_url from mpt_api_client.http.query_options import QueryOptions -from mpt_api_client.http.types import ( - HeaderTypes, - QueryParam, - RequestFiles, - Response, -) +from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response class AsyncHTTPClient: @@ -32,6 +23,9 @@ def __init__( timeout: float = 20.0, retries: int = 5, ): + retry = Retry(total=retries) + transport = RetryTransport(retry=retry) + api_token = api_token or os.getenv("MPT_API_TOKEN") if not api_token: raise ValueError( @@ -49,7 +43,7 @@ def __init__( base_url=base_url, headers=base_headers, timeout=timeout, - transport=AsyncHTTPTransport(retries=retries), + transport=transport, follow_redirects=True, ) diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 60421056..ff0bbab0 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -2,26 +2,14 @@ import os from typing import Any -from httpx import ( - Client, - HTTPError, - HTTPStatusError, - HTTPTransport, -) +from httpx import Client, HTTPError, HTTPStatusError +from httpx_retries import Retry, RetryTransport from mpt_api_client.constants import APPLICATION_JSON -from mpt_api_client.exceptions import ( - MPTError, - transform_http_status_exception, -) +from mpt_api_client.exceptions import MPTError, transform_http_status_exception from mpt_api_client.http.client_utils import get_query_params, validate_base_url from mpt_api_client.http.query_options import QueryOptions -from mpt_api_client.http.types import ( - HeaderTypes, - QueryParam, - RequestFiles, - Response, -) +from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response from mpt_api_client.models import ResourceData @@ -45,6 +33,9 @@ def __init__( timeout: float = 20.0, retries: int = 5, ): + retry = Retry(total=retries) + transport = RetryTransport(retry=retry) + api_token = api_token or os.getenv("MPT_API_TOKEN") if not api_token: raise ValueError( @@ -62,7 +53,7 @@ def __init__( base_url=base_url, headers=base_headers, timeout=timeout, - transport=HTTPTransport(retries=retries), + transport=transport, follow_redirects=True, ) diff --git a/pyproject.toml b/pyproject.toml index ebd8d39c..029608e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ ] dependencies = [ "httpx==0.28.*", + "httpx-retries==0.5.*", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 30305d8c..bf83666b 100644 --- a/uv.lock +++ b/uv.lock @@ -563,6 +563,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-retries" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/f5/046cac13877ce9b55aebdbb3999e0e45b19b989a95c5fd1040fa04bd1f92/httpx_retries-0.5.0.tar.gz", hash = "sha256:d8c8e1e0852d84be3837aba0bcf78aeb89a4b77db95e8cc988c8c058830b3044", size = 15647, upload-time = "2026-04-20T01:21:47.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/a8/aadeaa9a28510727d538636ee8688f0782a98523147852b29404ce696f1b/httpx_retries-0.5.0-py3-none-any.whl", hash = "sha256:d3124592979a9dc6197e666d1f02e9ab996a0c58fce59fad8db6201a6a87304e", size = 8908, upload-time = "2026-04-20T01:21:46.157Z" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -735,6 +747,7 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "httpx-retries" }, ] [package.dev-dependencies] @@ -767,7 +780,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = "==0.28.*" }] +requires-dist = [ + { name = "httpx", specifier = "==0.28.*" }, + { name = "httpx-retries", specifier = "==0.5.*" }, +] [package.metadata.requires-dev] dev = [ From 4ceec08c195d66324c5a4bf5f2300d6e14f701fd Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Mon, 20 Apr 2026 21:27:32 +0100 Subject: [PATCH 2/3] feat(http): add max retry error handling in http client --- mpt_api_client/exceptions.py | 7 +++++++ mpt_api_client/http/async_client.py | 13 ++++++++++--- mpt_api_client/http/client.py | 12 +++++++++--- tests/unit/http/test_async_client.py | 4 ++-- tests/unit/http/test_client.py | 6 +++--- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/mpt_api_client/exceptions.py b/mpt_api_client/exceptions.py index 168a5ea9..b7417188 100644 --- a/mpt_api_client/exceptions.py +++ b/mpt_api_client/exceptions.py @@ -17,6 +17,13 @@ def __init__(self, status_code: int, message: str, body: str): super().__init__(f"HTTP {status_code}: {message}") +class MPTMaxRetryError(MPTError): + """Represents an error when maximum retry attempts are exceeded.""" + + def __init__(self, message: str, attempts: int): + super().__init__(f"{message} error after {attempts} retry attempts.") + + class MPTAPIError(MPTHttpError): """Represents an API error.""" diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index a11eeb1e..10e7eeb5 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -1,11 +1,11 @@ import os from typing import Any -from httpx import AsyncClient, HTTPError, HTTPStatusError +from httpx import AsyncClient, HTTPError, HTTPStatusError, RequestError from httpx_retries import Retry, RetryTransport from mpt_api_client.constants import APPLICATION_JSON -from mpt_api_client.exceptions import MPTError, transform_http_status_exception +from mpt_api_client.exceptions import MPTError, MPTMaxRetryError, transform_http_status_exception from mpt_api_client.http.client import json_to_file_payload from mpt_api_client.http.client_utils import get_query_params, validate_base_url from mpt_api_client.http.query_options import QueryOptions @@ -23,7 +23,11 @@ def __init__( timeout: float = 20.0, retries: int = 5, ): - retry = Retry(total=retries) + self._retries = retries + retry = Retry( + total=retries, + allowed_methods={"DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"}, + ) transport = RetryTransport(retry=retry) api_token = api_token or os.getenv("MPT_API_TOKEN") @@ -80,6 +84,7 @@ async def request( # noqa: WPS211 MPTError: If the request fails. MPTApiError: If the response contains an error. MPTHttpError: If the response contains an HTTP error. + MPTMaxRetryError: If the request fails after maximum retry attempts. """ files = dict(files or {}) if force_multipart or (files and json): @@ -95,6 +100,8 @@ async def request( # noqa: WPS211 params=params_str or None, headers=headers, ) + except RequestError as err: + raise MPTMaxRetryError(str(err), self._retries + 1) from err except HTTPError as err: raise MPTError(f"HTTP Error: {err}") from err diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index ff0bbab0..88d9387d 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -2,11 +2,11 @@ import os from typing import Any -from httpx import Client, HTTPError, HTTPStatusError +from httpx import Client, HTTPError, HTTPStatusError, RequestError from httpx_retries import Retry, RetryTransport from mpt_api_client.constants import APPLICATION_JSON -from mpt_api_client.exceptions import MPTError, transform_http_status_exception +from mpt_api_client.exceptions import MPTError, MPTMaxRetryError, transform_http_status_exception from mpt_api_client.http.client_utils import get_query_params, validate_base_url from mpt_api_client.http.query_options import QueryOptions from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response @@ -33,7 +33,11 @@ def __init__( timeout: float = 20.0, retries: int = 5, ): - retry = Retry(total=retries) + self._retries = retries + retry = Retry( + total=self._retries, + allowed_methods={"DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"}, + ) transport = RetryTransport(retry=retry) api_token = api_token or os.getenv("MPT_API_TOKEN") @@ -105,6 +109,8 @@ def request( # noqa: WPS211 params=params_str or None, headers=headers, ) + except RequestError as err: + raise MPTMaxRetryError(str(err), self._retries + 1) from err except HTTPError as err: raise MPTError(f"HTTP Error: {err}") from err diff --git a/tests/unit/http/test_async_client.py b/tests/unit/http/test_async_client.py index 414bacab..7b60a446 100644 --- a/tests/unit/http/test_async_client.py +++ b/tests/unit/http/test_async_client.py @@ -79,10 +79,10 @@ async def test_async_http_call_success(async_http_client, mock_response): 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(MPTError, match="HTTP Error: Mock Timeout"): + with pytest.raises(MPTError, match=r"Mock Timeout error after 6 retry attempts."): await async_http_client.request("GET", "/timeout") - assert timeout_route.called + assert timeout_route.call_count == 6 async def test_http_call_with_json_and_files(mocker, async_http_client, mock_httpx_response): # noqa: WPS210 diff --git a/tests/unit/http/test_client.py b/tests/unit/http/test_client.py index 50a50fdb..56f022b8 100644 --- a/tests/unit/http/test_client.py +++ b/tests/unit/http/test_client.py @@ -5,7 +5,7 @@ import respx from httpx import ConnectTimeout, Response, codes -from mpt_api_client.exceptions import MPTError +from mpt_api_client.exceptions import MPTMaxRetryError from mpt_api_client.http.client import HTTPClient from mpt_api_client.http.query_options import QueryOptions from tests.unit.conftest import API_TOKEN, API_URL @@ -71,10 +71,10 @@ 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(MPTError, match="HTTP Error: Mock Timeout"): + with pytest.raises(MPTMaxRetryError, match=r"Mock Timeout error after 6 retry attempts."): http_client.request("GET", "/timeout") - assert timeout_route.called + assert timeout_route.call_count == 6 def test_http_call_with_json_and_files(mocker, http_client, mock_httpx_response): From 7975d70dd66f885548c94884ae170d3cb2ff9044 Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Tue, 21 Apr 2026 09:33:32 +0100 Subject: [PATCH 3/3] refactor(http): centralize response error handling with helper function --- mpt_api_client/http/async_client.py | 11 +++++------ mpt_api_client/http/client.py | 11 +++++------ mpt_api_client/http/request_response_utils.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 mpt_api_client/http/request_response_utils.py diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index 10e7eeb5..46a24ded 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -1,14 +1,15 @@ import os from typing import Any -from httpx import AsyncClient, HTTPError, HTTPStatusError, RequestError +from httpx import AsyncClient, HTTPError, RequestError from httpx_retries import Retry, RetryTransport from mpt_api_client.constants import APPLICATION_JSON -from mpt_api_client.exceptions import MPTError, MPTMaxRetryError, transform_http_status_exception +from mpt_api_client.exceptions import MPTError, MPTMaxRetryError from mpt_api_client.http.client import json_to_file_payload from mpt_api_client.http.client_utils import get_query_params, validate_base_url from mpt_api_client.http.query_options import QueryOptions +from mpt_api_client.http.request_response_utils import handle_response_http_error from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response @@ -105,10 +106,8 @@ async def request( # noqa: WPS211 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 + handle_response_http_error(response) + return Response( headers=dict(response.headers), status_code=response.status_code, diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 88d9387d..8e6e9517 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -2,13 +2,14 @@ import os from typing import Any -from httpx import Client, HTTPError, HTTPStatusError, RequestError +from httpx import Client, HTTPError, RequestError from httpx_retries import Retry, RetryTransport from mpt_api_client.constants import APPLICATION_JSON -from mpt_api_client.exceptions import MPTError, MPTMaxRetryError, transform_http_status_exception +from mpt_api_client.exceptions import MPTError, MPTMaxRetryError from mpt_api_client.http.client_utils import get_query_params, validate_base_url from mpt_api_client.http.query_options import QueryOptions +from mpt_api_client.http.request_response_utils import handle_response_http_error from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response from mpt_api_client.models import ResourceData @@ -114,10 +115,8 @@ def request( # noqa: WPS211 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 + handle_response_http_error(response) + return Response( headers=dict(response.headers), status_code=response.status_code, diff --git a/mpt_api_client/http/request_response_utils.py b/mpt_api_client/http/request_response_utils.py new file mode 100644 index 00000000..75ab8c5b --- /dev/null +++ b/mpt_api_client/http/request_response_utils.py @@ -0,0 +1,12 @@ +from httpx import HTTPStatusError +from httpx import Response as HTTPXResponse + +from mpt_api_client.exceptions import transform_http_status_exception + + +def handle_response_http_error(response: HTTPXResponse) -> None: + """Handles HTTP response error by raising a transformed HTTPStatusError exception.""" + try: + response.raise_for_status() + except HTTPStatusError as http_status_exception: + raise transform_http_status_exception(http_status_exception) from http_status_exception