From 88b6edc01a2913d1f0d0340e42181508b5b57218 Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Sat, 16 May 2026 09:35:07 -0400 Subject: [PATCH] feat: exponential backoff on consecutive 429s from Parcel API Doubles the retry interval on each consecutive 429 (300s -> 600s -> 1200s ...), floored at the user's configured interval and capped at 1h. Resets to zero on the first successful response. Prevents the integration from continuing to poll at the same cadence when the Parcel API is enforcing a rolling/burst rate limit beyond the documented 20/hr. --- custom_components/parcelapp/const.py | 1 + custom_components/parcelapp/coordinator.py | 15 +++- tests/test_coordinator.py | 86 +++++++++++++++++++++- 3 files changed, 98 insertions(+), 4 deletions(-) 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