diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 8fb8a3f3cf6d..0fa6d7e05e9a 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -152,6 +152,8 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" + self.update_interval = VEHICLE_INTERVAL + try: # Check if the vehicle is awake using a free API call response = await self.api.vehicle() @@ -166,11 +168,16 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): except VehicleOffline: self.data["state"] = TeslaFleetState.ASLEEP return self.data - except RateLimited: - LOGGER.warning( - "%s rate limited, will skip refresh", - self.name, - ) + except RateLimited as e: + if isinstance(e.data, dict) and (after := e.data.get("after")): + LOGGER.warning( + "%s rate limited, will retry in %s seconds", + self.name, + after, + ) + self.update_interval = timedelta(seconds=int(after)) + else: + LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data except (InvalidToken, OAuthExpired) as e: _invalidate_access_token(self.hass, self.config_entry) @@ -180,8 +187,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): except TeslaFleetError as e: raise UpdateFailed(e.message) from e - self.update_interval = VEHICLE_INTERVAL - self.updated_once = True if self.api.pre2021 and data["state"] == TeslaFleetState.ONLINE: @@ -238,13 +243,13 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) try: data = (await self.api.live_status())["response"] except RateLimited as e: - if isinstance(e.data, dict) and "after" in e.data: + if isinstance(e.data, dict) and (after := e.data.get("after")): LOGGER.warning( "%s rate limited, will retry in %s seconds", self.name, - e.data["after"], + after, ) - self.update_interval = timedelta(seconds=int(e.data["after"])) + self.update_interval = timedelta(seconds=int(after)) else: LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data @@ -320,13 +325,13 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] except RateLimited as e: - if isinstance(e.data, dict) and "after" in e.data: + if isinstance(e.data, dict) and (after := e.data.get("after")): LOGGER.warning( "%s rate limited, will retry in %s seconds", self.name, - e.data["after"], + after, ) - self.update_interval = timedelta(seconds=int(e.data["after"])) + self.update_interval = timedelta(seconds=int(after)) else: LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data @@ -401,13 +406,13 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) try: data = (await self.api.site_info())["response"] except RateLimited as e: - if isinstance(e.data, dict) and "after" in e.data: + if isinstance(e.data, dict) and (after := e.data.get("after")): LOGGER.warning( "%s rate limited, will retry in %s seconds", self.name, - e.data["after"], + after, ) - self.update_interval = timedelta(seconds=int(e.data["after"])) + self.update_interval = timedelta(seconds=int(after)) else: LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 4aa8b8b44c44..e660cb767de1 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -377,31 +377,102 @@ async def test_vehicle_refresh_ratelimited( mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: - """Test coordinator refresh handles 429.""" + """Test coordinator refresh handles 429 and backs off using the after hint.""" + + await setup_platform(hass, normal_config_entry) + + after_seconds = VEHICLE_INTERVAL_SECONDS + 10 + mock_vehicle_data.side_effect = RateLimited({"after": str(after_seconds)}) + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "77" + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Not yet past the after hint, must not call + assert mock_vehicle_data.call_count == 2 + + freezer.tick(timedelta(seconds=after_seconds - VEHICLE_INTERVAL_SECONDS)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Exactly past the after hint, must call + assert mock_vehicle_data.call_count == 3 + + +async def test_vehicle_refresh_ratelimited_on_first_refresh( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_data: AsyncMock, +) -> None: + """Test coordinator handles 429 on the first refresh, before any data exists.""" mock_vehicle_data.side_effect = RateLimited( - {"after": VEHICLE_INTERVAL_SECONDS + 10} + {"after": str(VEHICLE_INTERVAL_SECONDS + 10)} ) await setup_platform(hass, normal_config_entry) assert (state := hass.states.get("sensor.test_battery_level")) assert state.state == "unknown" - mock_vehicle_data.reset_mock() + +async def test_vehicle_refresh_ratelimited_backoff_not_sticky( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_state: AsyncMock, + mock_vehicle_data: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the after backoff does not persist through an asleep refresh.""" + + await setup_platform(hass, normal_config_entry) + + after_seconds = VEHICLE_INTERVAL_SECONDS * 3 + mock_vehicle_data.side_effect = RateLimited({"after": str(after_seconds)}) + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_vehicle_state.call_count == 2 + assert mock_vehicle_data.call_count == 2 + + # The vehicle falls asleep, so the next refresh returns before vehicle_data + mock_vehicle_state.return_value = VEHICLE_ASLEEP + freezer.tick(timedelta(seconds=after_seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_vehicle_state.call_count == 3 + assert mock_vehicle_data.call_count == 2 freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get("sensor.test_battery_level")) - assert state.state == "unknown" + # Back on the normal interval rather than the after backoff + assert mock_vehicle_state.call_count == 4 +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="missing"), + # The library sets after to None when the Retry-After header is absent + pytest.param({"reset": None, "after": None}, id="none"), + ], +) async def test_vehicle_refresh_ratelimited_no_after( hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, + data: dict[str, str | None], ) -> None: """Test coordinator refresh handles 429 without after.""" @@ -409,13 +480,15 @@ async def test_vehicle_refresh_ratelimited_no_after( # mock_vehicle_data called once during setup assert mock_vehicle_data.call_count == 1 - mock_vehicle_data.side_effect = RateLimited({}) + mock_vehicle_data.side_effect = RateLimited(data) freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() # Called again during refresh, failed with RateLimited assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "77" freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) @@ -773,7 +846,9 @@ async def test_energy_live_refresh_ratelimited( await setup_platform(hass, normal_config_entry) - mock_live_status.side_effect = RateLimited({"after": ENERGY_INTERVAL_SECONDS + 10}) + mock_live_status.side_effect = RateLimited( + {"after": str(ENERGY_INTERVAL_SECONDS + 10)} + ) freezer.tick(ENERGY_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -797,7 +872,7 @@ async def test_energy_live_refresh_ratelimited( @pytest.mark.parametrize( ("side_effect", "second_refresh_call_count", "third_refresh_call_count"), [ - (RateLimited({"after": ENERGY_INTERVAL_SECONDS + 10}), 2, 3), + (RateLimited({"after": str(ENERGY_INTERVAL_SECONDS + 10)}), 2, 3), (RateLimited({}), 3, 4), ], ) @@ -847,7 +922,7 @@ async def test_energy_history_refresh_ratelimited( await setup_platform(hass, normal_config_entry) mock_energy_history.side_effect = RateLimited( - {"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)} + {"after": str(int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10))} ) freezer.tick(ENERGY_HISTORY_INTERVAL) async_fire_time_changed(hass)