Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add retries functionality #178

Merged
merged 25 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
190a2dd
Add retries and tests
MatthewFlamm Apr 25, 2024
4583344
Merge branch 'master' into retries
MatthewFlamm Apr 25, 2024
4736bc0
remove one more event_loop from merge
MatthewFlamm Apr 25, 2024
4a5bb5b
remove unneeded import
MatthewFlamm Apr 25, 2024
2fbf04a
import and typing
MatthewFlamm Apr 25, 2024
3538488
better docstring
MatthewFlamm Apr 25, 2024
88e1562
only retry for HTTPServerErrors
MatthewFlamm Apr 25, 2024
730dec1
Merge branch 'master' into retries
MatthewFlamm Apr 25, 2024
d2b2260
ignore pylint errors
MatthewFlamm Apr 25, 2024
fd47a2d
add test for retry through aiohttp server
MatthewFlamm Apr 25, 2024
8855361
use ClientResponseError and check for >=500 codes
MatthewFlamm Apr 25, 2024
75fd467
exception typing
MatthewFlamm Apr 25, 2024
4962a45
use compact check
MatthewFlamm Apr 26, 2024
a58725b
also mock error check
MatthewFlamm Apr 26, 2024
d53d867
add test for 400 error
MatthewFlamm Apr 26, 2024
920bf56
lint
MatthewFlamm Apr 26, 2024
dba0526
Try direct attribute setting.
MatthewFlamm Apr 26, 2024
3685d02
no longer need monkeypatch
MatthewFlamm Apr 26, 2024
d34dafd
Remove monkeypatch fixture from test
MatthewFlamm Apr 26, 2024
bd8ea86
fix tests
MatthewFlamm Apr 26, 2024
d01d36c
make standalone function
MatthewFlamm Apr 27, 2024
18bcbd8
Merge branch 'master' into retries
MatthewFlamm Apr 29, 2024
e6f50c5
address linting from merge
MatthewFlamm Apr 29, 2024
0e0f013
remove .pylintrc
MatthewFlamm Apr 29, 2024
e4f599d
remove additional reference to pylint
MatthewFlamm Apr 29, 2024
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
10 changes: 0 additions & 10 deletions .pylintrc

This file was deleted.

11 changes: 9 additions & 2 deletions pynws/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
from .const import version
from .forecast import DetailedForecast
from .nws import Nws, NwsError
from .simple_nws import SimpleNWS
from .simple_nws import SimpleNWS, call_with_retry

__all__ = ["version", "DetailedForecast", "Nws", "NwsError", "SimpleNWS"]
__all__ = [
"version",
"DetailedForecast",
"Nws",
"NwsError",
"SimpleNWS",
"call_with_retry",
]
68 changes: 65 additions & 3 deletions pynws/simple_nws.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@

from datetime import datetime, timezone
from statistics import mean
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union

from aiohttp import ClientSession
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
NamedTuple,
Optional,
Set,
Tuple,
Union,
)

if TYPE_CHECKING:
from datetime import timedelta

from aiohttp import ClientResponseError, ClientSession
from metar import Metar

from .const import ALERT_ID, API_WEATHER_CODE, Final
Expand Down Expand Up @@ -37,6 +52,53 @@
WIND: Final = {name: idx * 360 / 16 for idx, name in enumerate(WIND_DIRECTIONS)}


def _is_500_error(error: BaseException) -> bool:
"""Return True if error is ClientResponseError and has a 5xx status."""
return isinstance(error, ClientResponseError) and error.status >= 500


def _setup_retry_func(
func: Callable[[Any, Any], Awaitable[Any]],
interval: Union[float, timedelta],
stop: Union[float, timedelta],
) -> Callable[[Any, Any], Awaitable[Any]]:
from tenacity import retry, retry_if_exception, stop_after_delay, wait_fixed

return retry(
reraise=True,
wait=wait_fixed(interval),
stop=stop_after_delay(stop),
retry=retry_if_exception(_is_500_error),
)(func)


async def call_with_retry(
func: Callable[[Any, Any], Awaitable[Any]],
interval: Union[float, timedelta],
stop: Union[float, timedelta],
/,
*args,
**kwargs,
) -> Callable[[Any, Any], Awaitable[Any]]:
"""Call an update function with retries.

Parameters
----------
func : Callable
An awaitable coroutine to retry.
interval : float, datetime.datetime.timedelta
Time interval for retry.
stop : float, datetime.datetime.timedelta
Time interval to stop retrying.
args : Any
Positional args to pass to func.
kwargs : Any
Keyword args to pass to func.
"""
retried_func = _setup_retry_func(func, interval, stop)
return await retried_func(*args, **kwargs)


class MetarParam(NamedTuple):
"""METAR conversion parameter"""

Expand Down
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pytest-asyncio==0.23.6
pytest-cov==5.0.0
pytest-aiohttp==1.0.5
mypy==1.9.0
tenacity==8.2.3
ruff==0.3.7
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
aiohttp
metar
tenacity
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"aiohttp",
"metar",
],
extras_require={"retry": ["tenacity"]},
python_requires=">=3.8",
classifiers=[
"License :: OSI Approved :: MIT License",
Expand Down
18 changes: 11 additions & 7 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
DIR = "tests/fixtures"


def data_return_function(file_name):
def data_return_function(input):
async def function(request):
if isinstance(file_name, str):
with open(os.path.join(DIR, file_name)) as f:
if isinstance(input, str):
with open(os.path.join(DIR, input)) as f:
return aiohttp.web.json_response(data=json.load(f))
elif isinstance(file_name, list):
with open(os.path.join(DIR, file_name.pop(0))) as f:
return aiohttp.web.json_response(data=json.load(f))
return None
elif isinstance(input, list):
input0 = input.pop(0)
if isinstance(input0, str):
with open(os.path.join(DIR, input0)) as f:
return aiohttp.web.json_response(data=json.load(f))
if issubclass(input0, Exception):
raise input0
raise RuntimeError("Unexpected input")

return function

Expand Down
85 changes: 84 additions & 1 deletion tests/test_simple_nws.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from unittest.mock import AsyncMock, patch

import aiohttp
from freezegun import freeze_time
import pytest

from pynws import NwsError, SimpleNWS
from pynws import NwsError, SimpleNWS, call_with_retry
from tests.helpers import setup_app

LATLON = (0, 0)
Expand Down Expand Up @@ -61,6 +64,43 @@ async def test_nws_observation(aiohttp_client, mock_urls, observation_json):
assert observation["iconWeather"][0][1] is None


async def test_nws_observation_with_retry(aiohttp_client, mock_urls):
# update fails without retry
app = setup_app(
stations_observations=[aiohttp.web.HTTPBadGateway, "stations_observations.json"]
)
client = await aiohttp_client(app)
nws = SimpleNWS(*LATLON, USERID, client)
await nws.set_station(STATION)

with pytest.raises(aiohttp.ClientResponseError):
await nws.update_observation()

# update succeeds with retry
app = setup_app(
stations_observations=[aiohttp.web.HTTPBadGateway, "stations_observations.json"]
)
client = await aiohttp_client(app)
nws = SimpleNWS(*LATLON, USERID, client)
await nws.set_station(STATION)

await call_with_retry(nws.update_observation, 0, 5)
observation = nws.observation
assert observation
assert observation["temperature"] == 10

# no retry for 4xx error
app = setup_app(
stations_observations=[aiohttp.web.HTTPBadRequest, "stations_observations.json"]
)
client = await aiohttp_client(app)
nws = SimpleNWS(*LATLON, USERID, client)

await nws.set_station(STATION)
with pytest.raises(aiohttp.ClientResponseError):
await call_with_retry(nws.update_observation, 0, 5)


async def test_nws_observation_units(aiohttp_client, mock_urls):
app = setup_app(stations_observations="stations_observations_alternate_units.json")
client = await aiohttp_client(app)
Expand Down Expand Up @@ -313,3 +353,46 @@ async def test_nws_alerts_all_zones_second_alert(aiohttp_client, mock_urls):
assert alerts
assert new_alerts == alerts
assert len(alerts) == 2


async def test_retries(aiohttp_client, mock_urls):
with patch("pynws.simple_nws._is_500_error") as err_mock:
# retry all exceptions
err_mock.return_value = True

app = setup_app()
client = await aiohttp_client(app)
nws = SimpleNWS(*LATLON, USERID, client)
await nws.set_station(STATION)

mock_update = AsyncMock()
mock_update.side_effect = [ValueError, None]

async def mock_wrap(*args, **kwargs):
return await mock_update(*args, **kwargs)

await call_with_retry(mock_wrap, 0, 5)

assert mock_update.call_count == 2

mock_update = AsyncMock()

async def mock_wrap(*args, **kwargs):
return await mock_update(*args, **kwargs)

await call_with_retry(mock_wrap, 0, 5, "", test=None)

mock_update.assert_called_once_with("", test=None)

# positional only args
with pytest.raises(TypeError):
call_with_retry(mock_wrap, interval=0, stop=5)

mock_update = AsyncMock()
mock_update.side_effect = [RuntimeError, None]

async def mock_wrap(*args, **kwargs):
return await mock_update(*args, **kwargs)

with pytest.raises(RuntimeError):
await call_with_retry(mock_wrap, 0, 5)