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>
This commit is contained in:
tmenguy 2025-04-01 17:14:21 +02:00 committed by GitHub
parent 23b79b2f39
commit da9b3dc68b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 81 additions and 5 deletions

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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),

View File

@ -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