mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Fix irm_kmi serving stale data indefinitely after API errors (#182795)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""DataUpdateCoordinator for the IRM KMI integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import override
|
||||
from typing import Final, override
|
||||
|
||||
from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError
|
||||
|
||||
@@ -14,13 +14,15 @@ from homeassistant.helpers.update_coordinator import (
|
||||
UpdateFailed,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .data import ProcessedCoordinatorData
|
||||
from .utils import preferred_language
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL: Final = timedelta(minutes=7)
|
||||
GRACE_PERIOD: Final = 2.5 * UPDATE_INTERVAL
|
||||
|
||||
type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator]
|
||||
|
||||
|
||||
@@ -39,19 +41,22 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData]
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="IRM KMI weather",
|
||||
update_interval=timedelta(minutes=7),
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self._api = api_client
|
||||
self._location = entry.data[CONF_LOCATION]
|
||||
# last_update_success_time is also renewed while serving old data
|
||||
self._last_api_success_time: datetime | None = None
|
||||
|
||||
def _within_grace(self, last_success: datetime | None) -> bool:
|
||||
"""Return whether data from the last success may still be served."""
|
||||
return (
|
||||
last_success is not None and dt_util.utcnow() - last_success < GRACE_PERIOD
|
||||
)
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> ProcessedCoordinatorData:
|
||||
"""Fetch data from API endpoint.
|
||||
|
||||
Pre-process the data to lookup tables so entities
|
||||
can quickly look up their data.
|
||||
"""
|
||||
|
||||
"""Fetch and process the IRM KMI data."""
|
||||
self._api.expire_cache()
|
||||
|
||||
try:
|
||||
@@ -63,26 +68,17 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData]
|
||||
)
|
||||
|
||||
except IrmKmiApiError as err:
|
||||
if (
|
||||
self.last_update_success_time is not None
|
||||
and self.update_interval is not None
|
||||
and self.last_update_success_time - utcnow()
|
||||
< timedelta(seconds=2.5 * self.update_interval.seconds)
|
||||
):
|
||||
if self._within_grace(self._last_api_success_time):
|
||||
return self.data
|
||||
|
||||
_LOGGER.warning(
|
||||
"Could not connect to the API since %s", self.last_update_success_time
|
||||
)
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with API for general forecast: {err}. "
|
||||
f"Last success time is: {self.last_update_success_time}"
|
||||
f"Last success time is: {self._last_api_success_time}"
|
||||
) from err
|
||||
|
||||
if not self.last_update_success:
|
||||
_LOGGER.warning("Successfully reconnected to the API")
|
||||
|
||||
return await self.process_api_data()
|
||||
data = await self.process_api_data()
|
||||
# Only once processed, so the grace period never serves missing data
|
||||
self._last_api_success_time = dt_util.utcnow()
|
||||
return data
|
||||
|
||||
async def process_api_data(self) -> ProcessedCoordinatorData:
|
||||
"""From the API data, create the object that will be used in the entities."""
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"""Tests for the IRM KMI integration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from irm_kmi_api import IrmKmiApiError
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
from .const import WEATHER_ENTITY_ID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_irm_kmi_api")
|
||||
@@ -33,6 +37,7 @@ async def test_config_entry_not_ready(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_irm_kmi_api: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test the IRM KMI configuration entry not ready."""
|
||||
mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError
|
||||
@@ -41,3 +46,75 @@ async def test_config_entry_not_ready(
|
||||
|
||||
assert mock_irm_kmi_api.refresh_forecasts_coord.call_count == 1
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
# The first refresh has no last success time to compare the grace period to
|
||||
assert "Unexpected error fetching" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00")
|
||||
async def test_entity_unavailable_after_grace_period(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_get_forecasts_coord: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that the last known data is only served for the grace period."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
mock_get_forecasts_coord.side_effect = IrmKmiApiError
|
||||
|
||||
freezer.tick(timedelta(minutes=8))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
freezer.tick(timedelta(minutes=7))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
freezer.tick(timedelta(minutes=7))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE
|
||||
|
||||
mock_get_forecasts_coord.side_effect = None
|
||||
|
||||
freezer.tick(timedelta(minutes=8))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00")
|
||||
async def test_grace_period_starts_at_the_last_usable_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_irm_kmi_api: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the grace period runs from the last refresh that produced data."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
mock_irm_kmi_api.get_daily_forecast.side_effect = TypeError
|
||||
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE
|
||||
|
||||
mock_irm_kmi_api.refresh_forecasts_coord.side_effect = IrmKmiApiError
|
||||
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(WEATHER_ENTITY_ID).state == STATE_UNAVAILABLE
|
||||
|
||||
Reference in New Issue
Block a user