From da9b3dc68b004f80d0acba76cd6b17e6bc4336a8 Mon Sep 17 00:00:00 2001 From: tmenguy Date: Tue, 1 Apr 2025 17:14:21 +0200 Subject: [PATCH] Better throttling handling for the Renault API (#141667) * Added some better throttling handling for the Renault API, it fixes #106777 HA ticket * Added some better throttling handling for the Renault API, it fixes #106777 HA ticket, test fixing * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * Adjust tests * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/renault/test_sensor.py * Update tests/components/renault/test_sensor.py * Update tests/components/renault/test_sensor.py * requested changes #106777 HA ticket * Use unkown * Fix test * Fix test again * Reduce and fix * Use assumed_state * requested changes #106777 HA ticket --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/renault/const.py | 3 ++ .../components/renault/coordinator.py | 28 +++++++++++++++++++ homeassistant/components/renault/entity.py | 5 ++++ .../components/renault/renault_hub.py | 27 +++++++++++++++++- .../components/renault/renault_vehicle.py | 4 +++ tests/components/renault/test_sensor.py | 19 ++++++++++--- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 201a07c6783..05f8099b168 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -9,6 +9,9 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# If throttled time to pause the updates, in seconds +COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index a90331730bc..c768c436133 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, + QuotaLimitException, ) from renault_api.kamereon.models import KamereonVehicleDataAttributes @@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub T = TypeVar("T", bound=KamereonVehicleDataAttributes) @@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, logger: logging.Logger, *, name: str, @@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self.assumed_state = False + self._has_already_worked = False + self._hub = hub async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" + + if self._hub.is_throttled(): + if not self._has_already_worked: + raise UpdateFailed("Renault hub currently throttled: init skipped") + # we have been throttled and decided to cooldown + # so do not count this update as an error + # coordinator. last_update_success should still be ok + self.logger.debug("Renault hub currently throttled: scan skipped") + self.assumed_state = True + return self.data + try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() @@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err + except QuotaLimitException as err: + # The data we got is not bad per see, initiate cooldown for all coordinators + self._hub.set_throttled() + if self._has_already_worked: + self.assumed_state = True + self.logger.warning("Renault API throttled") + return self.data + + raise UpdateFailed(f"Renault API throttled: {err}") from err + except NotSupportedException as err: # Disable because the vehicle does not support this Renault endpoint. self.update_interval = None @@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise UpdateFailed(f"Error communicating with API: {err}") from err self._has_already_worked = True + self.assumed_state = False return data async def async_config_entry_first_refresh(self) -> None: diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 7beb91e9603..81d81a18b7f 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -60,3 +60,8 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" return cast(StateType, getattr(self.coordinator.data, key)) + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self.coordinator.assumed_state diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b37390526cf..e5168fc81fd 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -27,7 +27,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession if TYPE_CHECKING: from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from time import time + +from .const import ( + CONF_KAMEREON_ACCOUNT_ID, + COOLING_UPDATES_SECONDS, + DEFAULT_SCAN_INTERVAL, +) from .renault_vehicle import RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -45,6 +51,24 @@ class RenaultHub: self._account: RenaultAccount | None = None self._vehicles: dict[str, RenaultVehicleProxy] = {} + self._got_throttled_at_time: float | None = None + + def set_throttled(self) -> None: + """We got throttled, we need to adjust the rate limit.""" + if self._got_throttled_at_time is None: + self._got_throttled_at_time = time() + + def is_throttled(self) -> bool: + """Check if we are throttled.""" + if self._got_throttled_at_time is None: + return False + + if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS: + self._got_throttled_at_time = None + return False + + return True + async def attempt_login(self, username: str, password: str) -> bool: """Attempt login to Renault servers.""" try: @@ -99,6 +123,7 @@ class RenaultHub: vehicle = RenaultVehicleProxy( hass=self._hass, config_entry=config_entry, + hub=self, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1cce0e4459f..1ab9bf0bd5a 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -68,6 +69,7 @@ class RenaultVehicleProxy: self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, @@ -87,6 +89,7 @@ class RenaultVehicleProxy: self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 self._scan_interval = scan_interval + self._hub = hub @property def details(self) -> models.KamereonVehicleDetails: @@ -104,6 +107,7 @@ class RenaultVehicleProxy: coord.key: RenaultDataUpdateCoordinator( self.hass, self.config_entry, + self._hub, LOGGER, name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index fb5fc205a7b..bce50ec4fbf 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -10,7 +10,7 @@ from renault_api.kamereon.exceptions import QuotaLimitException from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -184,7 +184,7 @@ async def test_sensor_throttling_during_setup( for get_data_mock in patches.values(): get_data_mock.side_effect = None patches["battery_status"].return_value.batteryLevel = 55 - freezer.tick(datetime.timedelta(minutes=10)) + freezer.tick(datetime.timedelta(minutes=20)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -196,6 +196,7 @@ async def test_sensor_throttling_after_init( hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str, + caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ) -> None: """Test for Renault sensors with a throttling error during setup.""" @@ -209,8 +210,11 @@ async def test_sensor_throttling_after_init( # Initial state entity_id = "sensor.reg_number_battery" assert hass.states.get(entity_id).state == "60" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled: scan skipped" not in caplog.text # Test QuotaLimitException state + caplog.clear() for get_data_mock in patches.values(): get_data_mock.side_effect = QuotaLimitException( "err.func.wired.overloaded", "You have reached your quota limit" @@ -219,14 +223,21 @@ async def test_sensor_throttling_after_init( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == "60" + assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" in caplog.text + assert "Renault hub currently throttled: scan skipped" in caplog.text # Test QuotaLimitException recovery, with new battery level + caplog.clear() for get_data_mock in patches.values(): get_data_mock.side_effect = None patches["battery_status"].return_value.batteryLevel = 55 - freezer.tick(datetime.timedelta(minutes=10)) + freezer.tick(datetime.timedelta(minutes=20)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "55" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" not in caplog.text + assert "Renault hub currently throttled: scan skipped" not in caplog.text