Defer the next ViCare refresh to the API quota reset (#181634)

This commit is contained in:
Christian Lackas
2026-09-10 14:13:59 +02:00
committed by GitHub
parent 86501aa473
commit 4302e921ef
4 changed files with 172 additions and 3 deletions
@@ -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
+14
View File
@@ -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)
+107
View File
@@ -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,
+42 -2
View File
@@ -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