Fix irm_kmi serving stale data indefinitely after API errors (#182795)

This commit is contained in:
YP30
2026-09-21 18:52:56 +02:00
committed by GitHub
parent 8358c9c144
commit d0cd63535f
2 changed files with 100 additions and 27 deletions
+21 -25
View File
@@ -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."""
+79 -2
View File
@@ -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