Handle API rate limit error on Home Connect entities fetch (#139384)

* Handle API rate limit error on entities fetch

* Apply suggestions

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add decorator (does not work)

* Fix decorator

* Apply suggestions

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add test

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo 2025-03-19 18:53:14 +01:00 committed by Franck Nijhof
parent 65aef40a3f
commit 43e24cf833
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
8 changed files with 431 additions and 35 deletions

View File

@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key
DOMAIN = "home_connect"
API_DEFAULT_RETRY_AFTER = 60
APPLIANCES_WITH_PROGRAMS = (
"CleaningRobot",

View File

@ -1,21 +1,28 @@
"""Home Connect entity base class."""
from abc import abstractmethod
from collections.abc import Callable, Coroutine
import contextlib
from datetime import datetime
import logging
from typing import cast
from typing import Any, Concatenate, cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from aiohomeconnect.model.error import (
ActiveProgramNotSetError,
HomeConnectError,
TooManyRequestsError,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
@ -127,3 +134,34 @@ class HomeConnectOptionEntity(HomeConnectEntity):
def bsh_key(self) -> OptionKey:
"""Return the BSH key."""
return cast(OptionKey, self.entity_description.key)
def constraint_fetcher[_EntityT: HomeConnectEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate the function to catch Home Connect too many requests error and retry later.
If it needs to be called later, it will call async_write_ha_state function
"""
async def handler_to_return(
self: _EntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
async def handler(_datetime: datetime | None = None) -> None:
try:
await func(self, *args, **kwargs)
except TooManyRequestsError as err:
if (retry_after := err.retry_after) is None:
retry_after = API_DEFAULT_RETRY_AFTER
async_call_later(self.hass, retry_after, handler)
except HomeConnectError as err:
_LOGGER.error(
"Error fetching constraints for %s: %s", self.entity_id, err
)
else:
if _datetime is not None:
self.async_write_ha_state()
await handler()
return handler_to_return

View File

@ -25,7 +25,7 @@ from .const import (
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@ -189,19 +189,25 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
},
) from err
@constraint_fetcher
async def async_fetch_constraints(self) -> None:
"""Fetch the max and min values and step for the number entity."""
try:
setting_key = cast(SettingKey, self.bsh_key)
data = self.appliance.settings.get(setting_key)
if not data or not data.unit or not data.constraints:
data = await self.coordinator.client.get_setting(
self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key)
self.appliance.info.ha_id, setting_key=setting_key
)
except HomeConnectError as err:
_LOGGER.error("An error occurred: %s", err)
else:
if data.unit:
self._attr_native_unit_of_measurement = data.unit
self.set_constraints(data)
def set_constraints(self, setting: GetSetting) -> None:
"""Set constraints for the number entity."""
if setting.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(
setting.unit, setting.unit
)
if not (constraints := setting.constraints):
return
if constraints.max:
@ -222,10 +228,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
"""When entity is added to hass."""
await super().async_added_to_hass()
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
self._attr_native_unit_of_measurement = data.unit
self.set_constraints(data)
if (
not hasattr(self, "_attr_native_min_value")
not hasattr(self, "_attr_native_unit_of_measurement")
or not hasattr(self, "_attr_native_min_value")
or not hasattr(self, "_attr_native_max_value")
or not hasattr(self, "_attr_native_step")
):
@ -253,7 +259,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
or candidate_unit != self._attr_native_unit_of_measurement
):
self._attr_native_unit_of_measurement = candidate_unit
self.__dict__.pop("unit_of_measurement", None)
option_constraints = option_definition.constraints
if option_constraints:
if (

View File

@ -1,8 +1,8 @@
"""Provides a select platform for Home Connect."""
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass
import logging
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
@ -47,9 +47,11 @@ from .coordinator import (
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
@ -458,17 +460,21 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
await self.async_fetch_options()
@constraint_fetcher
async def async_fetch_options(self) -> None:
"""Fetch options from the API."""
setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
if (
not setting
or not setting.constraints
or not setting.constraints.allowed_values
):
with contextlib.suppress(HomeConnectError):
setting = await self.coordinator.client.get_setting(
self.appliance.info.ha_id,
setting_key=cast(SettingKey, self.bsh_key),
)
setting = await self.coordinator.client.get_setting(
self.appliance.info.ha_id,
setting_key=cast(SettingKey, self.bsh_key),
)
if setting and setting.constraints and setting.constraints.allowed_values:
self._attr_options = [

View File

@ -1,12 +1,11 @@
"""Provides a sensor for Home Connect."""
import contextlib
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import cast
from aiohomeconnect.model import EventKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -28,7 +27,9 @@ from .const import (
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .entity import HomeConnectEntity, constraint_fetcher
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@ -335,16 +336,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
else:
await self.fetch_unit()
@constraint_fetcher
async def fetch_unit(self) -> None:
"""Fetch the unit of measurement."""
with contextlib.suppress(HomeConnectError):
data = await self.coordinator.client.get_status_value(
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
)
if data.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(
data.unit, data.unit
)
data = await self.coordinator.client.get_status_value(
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
)
if data.unit:
self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit)
class HomeConnectProgramSensor(HomeConnectSensor):

View File

@ -2,7 +2,7 @@
from collections.abc import Awaitable, Callable
import random
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from aiohomeconnect.model import (
ArrayOfEvents,
@ -22,6 +22,7 @@ from aiohomeconnect.model.error import (
HomeConnectApiError,
HomeConnectError,
SelectedProgramNotSetError,
TooManyRequestsError,
)
from aiohomeconnect.model.program import (
ProgramDefinitionConstraints,
@ -47,7 +48,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
@ -340,6 +341,98 @@ async def test_number_entity_functionality(
assert hass.states.is_state(entity_id, str(float(value)))
@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True)
@pytest.mark.parametrize("retry_after", [0, None])
@pytest.mark.parametrize(
(
"entity_id",
"setting_key",
"type",
"min_value",
"max_value",
"step_size",
"unit_of_measurement",
),
[
(
f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
"Double",
7,
15,
5,
"°C",
),
],
)
@patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0)
async def test_fetch_constraints_after_rate_limit_error(
retry_after: int | None,
appliance_ha_id: str,
entity_id: str,
setting_key: SettingKey,
type: str,
min_value: int,
max_value: int,
step_size: int,
unit_of_measurement: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that, if a API rate limit error is raised, the constraints are fetched later."""
def get_settings_side_effect(ha_id: str):
if ha_id != appliance_ha_id:
return ArrayOfSettings([])
return ArrayOfSettings(
[
GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=random.randint(min_value, max_value),
)
]
)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_setting = AsyncMock(
side_effect=[
TooManyRequestsError("error.key", retry_after=retry_after),
GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=random.randint(min_value, max_value),
unit=unit_of_measurement,
type=type,
constraints=SettingConstraints(
min=min_value,
max=max_value,
step_size=step_size,
),
),
]
)
assert config_entry.state is ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert client.get_setting.call_count == 2
entity_state = hass.states.get(entity_id)
assert entity_state
attributes = entity_state.attributes
assert attributes["min"] == min_value
assert attributes["max"] == max_value
assert attributes["step"] == step_size
assert attributes["unit_of_measurement"] == unit_of_measurement
@pytest.mark.parametrize(
("entity_id", "setting_key", "mock_attr"),
[

View File

@ -21,6 +21,7 @@ from aiohomeconnect.model.error import (
ActiveProgramNotSetError,
HomeConnectError,
SelectedProgramNotSetError,
TooManyRequestsError,
)
from aiohomeconnect.model.program import (
EnumerateProgram,
@ -50,7 +51,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
@ -566,6 +567,139 @@ async def test_fetch_allowed_values(
assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options
@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True)
@pytest.mark.parametrize(
(
"entity_id",
"setting_key",
"allowed_values",
"expected_options",
),
[
(
"select.hood_ambient_light_color",
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
[f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)],
{str(i) for i in range(1, 50)},
),
],
)
async def test_fetch_allowed_values_after_rate_limit_error(
appliance_ha_id: str,
entity_id: str,
setting_key: SettingKey,
allowed_values: list[str | None],
expected_options: set[str],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test fetch allowed values."""
def get_settings_side_effect(ha_id: str):
if ha_id != appliance_ha_id:
return ArrayOfSettings([])
return ArrayOfSettings(
[
GetSetting(
key=setting_key,
raw_key=setting_key.value,
value="", # Not important
)
]
)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_setting = AsyncMock(
side_effect=[
TooManyRequestsError("error.key", retry_after=0),
GetSetting(
key=setting_key,
raw_key=setting_key.value,
value="", # Not important
constraints=SettingConstraints(
allowed_values=allowed_values,
),
),
]
)
assert config_entry.state is ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert client.get_setting.call_count == 2
entity_state = hass.states.get(entity_id)
assert entity_state
assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options
@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True)
@pytest.mark.parametrize(
(
"entity_id",
"setting_key",
"exception",
"expected_options",
),
[
(
"select.hood_ambient_light_color",
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
HomeConnectError(),
{
"b_s_h_common_enum_type_ambient_light_color_custom_color",
*{str(i) for i in range(1, 100)},
},
),
],
)
async def test_default_values_after_fetch_allowed_values_error(
appliance_ha_id: str,
entity_id: str,
setting_key: SettingKey,
exception: Exception,
expected_options: set[str],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test fetch allowed values."""
def get_settings_side_effect(ha_id: str):
if ha_id != appliance_ha_id:
return ArrayOfSettings([])
return ArrayOfSettings(
[
GetSetting(
key=setting_key,
raw_key=setting_key.value,
value="", # Not important
)
]
)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_setting = AsyncMock(side_effect=exception)
assert config_entry.state is ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
assert client.get_setting.call_count == 1
entity_state = hass.states.get(entity_id)
assert entity_state
assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options
@pytest.mark.parametrize(
("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"),
[

View File

@ -13,7 +13,7 @@ from aiohomeconnect.model import (
Status,
StatusKey,
)
from aiohomeconnect.model.error import HomeConnectApiError
from aiohomeconnect.model.error import HomeConnectApiError, TooManyRequestsError
from freezegun.api import FrozenDateTimeFactory
import pytest
@ -26,12 +26,13 @@ from homeassistant.components.home_connect.const import (
BSH_EVENT_PRESENT_STATE_PRESENT,
DOMAIN,
)
from homeassistant.components.home_connect.coordinator import HomeConnectError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
TEST_HC_APP = "Dishwasher"
@ -724,3 +725,122 @@ async def test_sensor_unit_fetching(
)
assert client.get_status_value.call_count == get_status_value_call_count
@pytest.mark.parametrize(
(
"appliance_ha_id",
"entity_id",
"status_key",
),
[
(
"Oven",
"sensor.oven_current_oven_cavity_temperature",
StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
),
],
indirect=["appliance_ha_id"],
)
async def test_sensor_unit_fetching_error(
appliance_ha_id: str,
entity_id: str,
status_key: StatusKey,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that the sensor entities are capable of fetching units."""
async def get_status_mock(ha_id: str) -> ArrayOfStatus:
if ha_id != appliance_ha_id:
return ArrayOfStatus([])
return ArrayOfStatus(
[
Status(
key=status_key,
raw_key=status_key.value,
value=0,
)
]
)
client.get_status = AsyncMock(side_effect=get_status_mock)
client.get_status_value = AsyncMock(side_effect=HomeConnectError())
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert hass.states.get(entity_id)
@pytest.mark.parametrize(
(
"appliance_ha_id",
"entity_id",
"status_key",
"unit",
),
[
(
"Oven",
"sensor.oven_current_oven_cavity_temperature",
StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
"°C",
),
],
indirect=["appliance_ha_id"],
)
async def test_sensor_unit_fetching_after_rate_limit_error(
appliance_ha_id: str,
entity_id: str,
status_key: StatusKey,
unit: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that the sensor entities are capable of fetching units."""
async def get_status_mock(ha_id: str) -> ArrayOfStatus:
if ha_id != appliance_ha_id:
return ArrayOfStatus([])
return ArrayOfStatus(
[
Status(
key=status_key,
raw_key=status_key.value,
value=0,
)
]
)
client.get_status = AsyncMock(side_effect=get_status_mock)
client.get_status_value = AsyncMock(
side_effect=[
TooManyRequestsError("error.key", retry_after=0),
Status(
key=status_key,
raw_key=status_key.value,
value=0,
unit=unit,
),
]
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
assert client.get_status_value.call_count == 2
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.attributes["unit_of_measurement"] == unit