diff --git a/custom_components/parcelapp/const.py b/custom_components/parcelapp/const.py index a992e4a..c990731 100644 --- a/custom_components/parcelapp/const.py +++ b/custom_components/parcelapp/const.py @@ -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.", diff --git a/custom_components/parcelapp/coordinator.py b/custom_components/parcelapp/coordinator.py index 09efbec..10e92e0 100644 --- a/custom_components/parcelapp/coordinator.py +++ b/custom_components/parcelapp/coordinator.py @@ -21,6 +21,7 @@ STORAGE_KEY, STORAGE_VERSION, DEFAULT_RETRY_AFTER_SECONDS, + MAX_BACKOFF_SECONDS, ) _LOGGER = logging.getLogger(__name__) @@ -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, @@ -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) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index f7bc410..e924157 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file