diff --git a/src/pyanilist/_clients/_async.py b/src/pyanilist/_clients/_async.py index 44efd92..83f726d 100644 --- a/src/pyanilist/_clients/_async.py +++ b/src/pyanilist/_clients/_async.py @@ -4,7 +4,7 @@ import httpx from pydantic import validate_call -from tenacity import retry, stop_after_attempt, wait_incrementing +from tenacity import AsyncRetrying, stop_after_attempt, wait_incrementing from .._enums import MediaFormat, MediaSeason, MediaStatus, MediaType from .._models import Media @@ -14,7 +14,9 @@ class AsyncAnilist: - def __init__(self, api_url: str = "https://graphql.anilist.co", **httpx_async_client_kwargs: Any) -> None: + def __init__( + self, api_url: str = "https://graphql.anilist.co", retries: int = 5, **httpx_async_client_kwargs: Any + ) -> None: """ Async Anilist API client. @@ -22,18 +24,16 @@ def __init__(self, api_url: str = "https://graphql.anilist.co", **httpx_async_cl ---------- api_url : str, optional The URL of the Anilist API. Default is "https://graphql.anilist.co". + retries : int, optional + Number of times to retry a failed request before raising an error. Default is 5. httpx_async_client_kwargs : Any, optional Keyword arguments to pass to the internal [httpx.AsyncClient()](https://www.python-httpx.org/api/#asyncclient) used to make the POST request. """ self.api_url = api_url + self.retries = retries self.httpx_async_client_kwargs = httpx_async_client_kwargs - @retry( - stop=stop_after_attempt(5), - wait=wait_incrementing(start=0, increment=1), - reraise=True, - ) async def _post_request( self, id: AnilistID | None = None, @@ -92,9 +92,15 @@ async def _post_request( "variables": {key: value for key, value in query_variables.items() if value is not None}, } - async with httpx.AsyncClient(**self.httpx_async_client_kwargs) as client: - response = await client.post(self.api_url, json=payload) - response.raise_for_status() + async for attempt in AsyncRetrying( + stop=stop_after_attempt(self.retries), + wait=wait_incrementing(start=0, increment=1), + reraise=True, + ): + with attempt: + async with httpx.AsyncClient(**self.httpx_async_client_kwargs) as client: + response = await client.post(self.api_url, json=payload) + response.raise_for_status() return response diff --git a/src/pyanilist/_clients/_sync.py b/src/pyanilist/_clients/_sync.py index e369b75..b07afeb 100644 --- a/src/pyanilist/_clients/_sync.py +++ b/src/pyanilist/_clients/_sync.py @@ -4,7 +4,7 @@ import httpx from pydantic import validate_call -from tenacity import retry, stop_after_attempt, wait_incrementing +from tenacity import Retrying, stop_after_attempt, wait_incrementing from .._enums import MediaFormat, MediaSeason, MediaStatus, MediaType from .._models import Media @@ -14,7 +14,9 @@ class Anilist: - def __init__(self, api_url: str = "https://graphql.anilist.co", **httpx_client_kwargs: Any) -> None: + def __init__( + self, api_url: str = "https://graphql.anilist.co", retries: int = 5, **httpx_client_kwargs: Any + ) -> None: """ Anilist API client. @@ -22,18 +24,16 @@ def __init__(self, api_url: str = "https://graphql.anilist.co", **httpx_client_k ---------- api_url : str, optional The URL of the Anilist API. Default is "https://graphql.anilist.co". + retries : int, optional + Number of times to retry a failed request before raising an error. Default is 5. httpx_client_kwargs : Any, optional Keyword arguments to pass to the internal [httpx.Client()](https://www.python-httpx.org/api/#client) used to make the POST request. """ self.api_url = api_url + self.retries = retries self.httpx_client_kwargs = httpx_client_kwargs - @retry( - stop=stop_after_attempt(5), - wait=wait_incrementing(start=0, increment=1), - reraise=True, - ) def _post_request( self, id: AnilistID | None = None, @@ -92,8 +92,14 @@ def _post_request( "variables": {key: value for key, value in query_variables.items() if value is not None}, } - with httpx.Client(**self.httpx_client_kwargs) as client: - response = client.post(self.api_url, json=payload).raise_for_status() + for attempt in Retrying( + stop=stop_after_attempt(self.retries), + wait=wait_incrementing(start=0, increment=1), + reraise=True, + ): + with attempt: + with httpx.Client(**self.httpx_client_kwargs) as client: + response = client.post(self.api_url, json=payload).raise_for_status() return response diff --git a/tests/test_async_exceptions.py b/tests/test_async_exceptions.py index c0c6139..14dbb2c 100644 --- a/tests/test_async_exceptions.py +++ b/tests/test_async_exceptions.py @@ -6,22 +6,24 @@ ValidationError, ) +anilist = AsyncAnilist(retries=1) + async def test_anilist_title_doesnt_exist() -> None: with pytest.raises(HTTPStatusError, match="Not Found."): - await AsyncAnilist().search("Title does not exist", type=MediaType.MANGA) + await anilist.search("Title does not exist", type=MediaType.MANGA) async def test_anilist_bad_search_combo() -> None: with pytest.raises(HTTPStatusError, match="Not Found."): - await AsyncAnilist().search("Attack on titan", season_year=1999) + await anilist.search("Attack on titan", season_year=1999) async def test_anilist_wrong_input_types() -> None: with pytest.raises(ValidationError): - await AsyncAnilist().search(123456789, season_year="hello", type=True) # type: ignore + await anilist.search(123456789, season_year="hello", type=True) # type: ignore async def test_anilist_bad_id() -> None: with pytest.raises(HTTPStatusError, match="400 Bad Request"): - await AsyncAnilist().get(9999999999) + await anilist.get(9999999999) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index da1518e..5629ed5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,22 +6,24 @@ ValidationError, ) +anilist = Anilist(retries=1) + def test_anilist_title_doesnt_exist() -> None: with pytest.raises(HTTPStatusError, match="Not Found."): - Anilist().search("Title does not exist", type=MediaType.MANGA) + anilist.search("Title does not exist", type=MediaType.MANGA) def test_anilist_bad_search_combo() -> None: with pytest.raises(HTTPStatusError, match="Not Found."): - Anilist().search("Attack on titan", season_year=1999) + anilist.search("Attack on titan", season_year=1999) def test_anilist_wrong_input_types() -> None: with pytest.raises(ValidationError): - Anilist().search(123456789, season_year="hello", type=True) # type: ignore + anilist.search(123456789, season_year="hello", type=True) # type: ignore def test_anilist_bad_id() -> None: with pytest.raises(HTTPStatusError, match="400 Bad Request"): - Anilist().get(9999999999) + anilist.get(9999999999)