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
1 change: 1 addition & 0 deletions custom_components/parcelapp/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
STORAGE_KEY = f"{DOMAIN}_cache"
STORAGE_VERSION = 1
DEFAULT_RETRY_AFTER_SECONDS = 300
MAX_BACKOFF_SECONDS = 3600
DELIVERY_STATUS_CODES = {
-1: "None",
0: "Completed delivery.",
Expand Down
15 changes: 12 additions & 3 deletions custom_components/parcelapp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
STORAGE_KEY,
STORAGE_VERSION,
DEFAULT_RETRY_AFTER_SECONDS,
MAX_BACKOFF_SECONDS,
)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -42,6 +43,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self._store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}_{entry.entry_id}")
self._cached_data: dict[str, Any] | None = None
self._skip_next_update = False
self._consecutive_429s = 0

super().__init__(
hass,
Expand Down Expand Up @@ -136,17 +138,24 @@ async def _async_update_data(self) -> dict[str, Any]:
retry_after = int(retry_header)
except ValueError:
pass
self._consecutive_429s += 1
backoff = retry_after * (2 ** (self._consecutive_429s - 1))
backoff = min(backoff, MAX_BACKOFF_SECONDS)
backoff = max(backoff, self._configured_interval_seconds)
_LOGGER.warning(
"Parcel API rate limit hit (429). Backing off for %d seconds",
retry_after,
"Parcel API rate limit hit (429), attempt %d. Backing off for %d seconds",
self._consecutive_429s,
backoff,
)
if self._cached_data is not None:
self.update_interval = timedelta(seconds=retry_after)
self.update_interval = timedelta(seconds=backoff)
return self._cached_data
raise UpdateFailed(
"Rate limited by Parcel API (429) and no cached data available."
)

self._consecutive_429s = 0

response.raise_for_status()
payload = await response.text()
payload_json = json.loads(payload)
Expand Down
86 changes: 85 additions & 1 deletion tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,88 @@ async def test_parcel_update_coordinator(hass, aioclient_mock):

# Assert the data was fetched correctly
assert coordinator.last_update_success
assert coordinator.data['deliveries'] == recent_deliveries['deliveries'] # This is only looking at delivery data, not extra parcel info
assert coordinator.data['deliveries'] == recent_deliveries['deliveries'] # This is only looking at delivery data, not extra parcel info


@pytest.mark.asyncio
async def test_exponential_backoff_on_consecutive_429s(hass, aioclient_mock):
"""Consecutive 429s should double the backoff, reset on success."""
from datetime import timedelta
fixtures_path = Path(__file__).parent / "fixtures"
with open(fixtures_path / "recent.json") as file:
recent_deliveries = json.load(file)

carrier_codes_url = "https://api.parcel.app/external/supported_carriers.json"
mock_api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent"

mock_entry = AsyncMock()
mock_entry.data = {"api_key": "test_api_key"}
mock_entry.options = {"update_interval": 600}
mock_entry.entry_id = "test_entry_backoff"
mock_entry.async_on_unload = Mock()

coordinator = ParcelUpdateCoordinator(hass, mock_entry)
coordinator.api_key = "test_api_key"
coordinator.session = async_get_clientsession(hass)
coordinator._cached_data = recent_deliveries

aioclient_mock.get(carrier_codes_url, json={"fedex": "Fedex"}, status=200)
aioclient_mock.get(mock_api_url, status=429)

# 1st 429: 300 * 1 = 300, floored at configured 600
await coordinator.async_refresh()
assert coordinator._consecutive_429s == 1
assert coordinator.update_interval == timedelta(seconds=600)

# 2nd 429: 300 * 2 = 600
await coordinator.async_refresh()
assert coordinator._consecutive_429s == 2
assert coordinator.update_interval == timedelta(seconds=600)

# 3rd 429: 300 * 4 = 1200
await coordinator.async_refresh()
assert coordinator._consecutive_429s == 3
assert coordinator.update_interval == timedelta(seconds=1200)

# 4th 429: 300 * 8 = 2400
await coordinator.async_refresh()
assert coordinator._consecutive_429s == 4
assert coordinator.update_interval == timedelta(seconds=2400)

# Success resets the counter
aioclient_mock.clear_requests()
aioclient_mock.get(carrier_codes_url, json={"fedex": "Fedex"}, status=200)
aioclient_mock.get(mock_api_url, json=recent_deliveries, status=200)
await coordinator.async_refresh()
assert coordinator._consecutive_429s == 0


@pytest.mark.asyncio
async def test_backoff_capped_at_max(hass, aioclient_mock):
"""Backoff should not exceed MAX_BACKOFF_SECONDS."""
from datetime import timedelta
from custom_components.parcelapp.const import MAX_BACKOFF_SECONDS

fixtures_path = Path(__file__).parent / "fixtures"
with open(fixtures_path / "recent.json") as file:
recent_deliveries = json.load(file)

carrier_codes_url = "https://api.parcel.app/external/supported_carriers.json"
mock_api_url = "https://api.parcel.app/external/deliveries/?filter_mode=recent"

mock_entry = AsyncMock()
mock_entry.data = {"api_key": "test_api_key"}
mock_entry.options = {"update_interval": 300}
mock_entry.entry_id = "test_entry_cap"
mock_entry.async_on_unload = Mock()

coordinator = ParcelUpdateCoordinator(hass, mock_entry)
coordinator.api_key = "test_api_key"
coordinator.session = async_get_clientsession(hass)
coordinator._cached_data = recent_deliveries
coordinator._consecutive_429s = 9

aioclient_mock.get(carrier_codes_url, json={"fedex": "Fedex"}, status=200)
aioclient_mock.get(mock_api_url, status=429)
await coordinator.async_refresh()
assert coordinator.update_interval == timedelta(seconds=MAX_BACKOFF_SECONDS)
Loading