diff --git a/homeassistant/components/vicare/coordinator.py b/homeassistant/components/vicare/coordinator.py index 3661bf6e63bf..534c8ff8054c 100644 --- a/homeassistant/components/vicare/coordinator.py +++ b/homeassistant/components/vicare/coordinator.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_CACHE_DURATION, DOMAIN from .types import ViCareConfigEntry +from .utils import retry_after_from _LOGGER = logging.getLogger(__name__) @@ -73,11 +74,18 @@ class ViCareCoordinator(DataUpdateCoordinator[None]): ) except PyViCareInvalidCredentialsError as err: raise ConfigEntryAuthFailed from err + except PyViCareRateLimitError as err: + raise UpdateFailed( + str(err), + retry_after=retry_after_from( + err, + self.update_interval or timedelta(seconds=DEFAULT_CACHE_DURATION), + ), + ) from err except ( PyViCareDeviceCommunicationError, PyViCareInternalServerError, PyViCareInvalidDataError, - PyViCareRateLimitError, requests.RequestException, ) as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 3056e9b9d848..f10b20f90b9b 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -1,6 +1,7 @@ """ViCare helpers functions.""" from collections.abc import Callable, Mapping +from datetime import UTC, timedelta import logging from typing import Any @@ -21,9 +22,12 @@ import requests from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.util import dt as dt_util from .const import DEFAULT_CACHE_DURATION, VICARE_TOKEN_FILENAME +MAX_RATE_LIMIT_BACKOFF = 86400 # the quota window is a day + _LOGGER = logging.getLogger(__name__) @@ -157,3 +161,13 @@ def filter_state(state: str) -> str | None: def normalize_state(state: str) -> str: """Return the state with underscores instead of hyphens.""" return state.replace("-", "_") + + +def retry_after_from(error: PyViCareRateLimitError, floor: timedelta) -> float: + """Return seconds to wait after a rate limit, clamped to [floor, one day]. + + limitResetDate is naive UTC and can be in the past. + """ + reset = error.limitResetDate.replace(tzinfo=UTC) + delay = (reset - dt_util.utcnow()).total_seconds() + return min(max(delay, floor.total_seconds()), MAX_RATE_LIMIT_BACKOFF) diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py index 28899ea0dc87..ba59aa7ae1af 100644 --- a/tests/components/vicare/test_init.py +++ b/tests/components/vicare/test_init.py @@ -40,6 +40,11 @@ from .conftest import Fixture, MockPyViCare from tests.common import MockConfigEntry, async_fire_time_changed +# From a real rate limit response: 2026-09-09T00:00:04.144Z. +QUOTA_RESET_MS = 1788912004144 + +SENSOR_ID = "sensor.model0_outside_temperature" + # 16-character zigbee IEEE address shared by the FHT fixtures. ZIGBEE_IEEE = "#" * 16 @@ -311,6 +316,108 @@ async def test_setup_entry_transient_error( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_coordinator_backs_off_until_the_quota_resets( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a rate limited refresh defers the next one to the reset time.""" + freezer.move_to("2026-09-08 20:00:04+00:00") + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + mock_vicare = MockPyViCare(fixtures) + service = mock_vicare.devices[0].service + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + ): + await setup_integration(hass, mock_config_entry) + + service.fetch_all_features.side_effect = PyViCareRateLimitError( + { + "extendedPayload": { + "name": "development portal", + "requestCountLimit": 1450, + "limitReset": QUOTA_RESET_MS, + } + } + ) + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(SENSOR_ID).state == STATE_UNAVAILABLE + calls = service.fetch_all_features.call_count + + # The quota resets four hours out, so nothing may go out at the ordinary + # interval in between. + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count == calls + + freezer.tick(timedelta(hours=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count > calls + + +async def test_coordinator_backs_off_when_the_reset_has_passed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a reset time in the past still defers, instead of retrying at once.""" + freezer.move_to("2026-09-09 01:00:04+00:00") + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + mock_vicare = MockPyViCare(fixtures) + service = mock_vicare.devices[0].service + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + ): + await setup_integration(hass, mock_config_entry) + + service.fetch_all_features.side_effect = PyViCareRateLimitError( + { + "extendedPayload": { + "name": "development portal", + "requestCountLimit": 1450, + "limitReset": QUOTA_RESET_MS, + } + } + ) + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(SENSOR_ID).state == STATE_UNAVAILABLE + calls = service.fetch_all_features.call_count + + # A zero delay would reschedule at once and hammer a quota that is still + # spent, so the wait stays at the ordinary interval. + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count == calls + + freezer.tick(timedelta(seconds=DEFAULT_CACHE_DURATION * 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert service.fetch_all_features.call_count > calls + + async def test_setup_entry_invalid_credentials( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/vicare/test_utils.py b/tests/components/vicare/test_utils.py index 13ca77f27924..a92f11d2d926 100644 --- a/tests/components/vicare/test_utils.py +++ b/tests/components/vicare/test_utils.py @@ -1,8 +1,15 @@ """Test ViCare utils.""" -import pytest +from datetime import timedelta -from homeassistant.components.vicare.utils import filter_state +from freezegun.api import FrozenDateTimeFactory +import pytest +from PyViCare.PyViCareUtils import PyViCareRateLimitError + +from homeassistant.components.vicare.utils import filter_state, retry_after_from + +# From a real rate limit response: 2026-09-09T00:00:04.144Z. +QUOTA_RESET_MS = 1788912004144 @pytest.mark.parametrize( @@ -21,3 +28,36 @@ async def test_filter_state( """Test filter_state.""" assert filter_state(state) == expected_result + + +@pytest.mark.parametrize( + ("now", "floor", "expected_result"), + [ + ("2026-09-08 20:00:04+00:00", timedelta(seconds=60), 14400.144), + # A reset already passed must not retry at once. + ("2026-09-09 01:00:04+00:00", timedelta(seconds=60), 60), + # The floor is the caller's interval, not a fixed minute. + ("2026-09-09 01:00:04+00:00", timedelta(seconds=180), 180), + # Nothing waits longer than the quota window. + ("2026-09-06 00:00:04+00:00", timedelta(seconds=60), 86400), + ], +) +async def test_retry_after_from( + freezer: FrozenDateTimeFactory, + now: str, + floor: timedelta, + expected_result: float, +) -> None: + """Test the rate limit backoff is clamped to the caller's interval and a day.""" + freezer.move_to(now) + error = PyViCareRateLimitError( + { + "extendedPayload": { + "name": "development portal", + "requestCountLimit": 1450, + "limitReset": QUOTA_RESET_MS, + } + } + ) + + assert retry_after_from(error, floor) == expected_result