From d0cd63535fb1d3fb4c73cf8e3ba150ba5c3d69b4 Mon Sep 17 00:00:00 2001 From: YP30 <58120331+YP30@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:52:56 +0200 Subject: [PATCH] Fix irm_kmi serving stale data indefinitely after API errors (#182795) --- .../components/irm_kmi/coordinator.py | 46 +++++------ tests/components/irm_kmi/test_init.py | 81 ++++++++++++++++++- 2 files changed, 100 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/irm_kmi/coordinator.py b/homeassistant/components/irm_kmi/coordinator.py index feafb5b88b36..cea0057d5899 100644 --- a/homeassistant/components/irm_kmi/coordinator.py +++ b/homeassistant/components/irm_kmi/coordinator.py @@ -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.""" diff --git a/tests/components/irm_kmi/test_init.py b/tests/components/irm_kmi/test_init.py index 8adedfdda389..82e628d055dd 100644 --- a/tests/components/irm_kmi/test_init.py +++ b/tests/components/irm_kmi/test_init.py @@ -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