mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Defer the next ViCare refresh to the API quota reset (#181634)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user