Skip to content

Commit 84f8e57

Browse files
erwindounaMartinHjelmareCopilot
authored
Add retry_after to UpdateFailed in update coordinator (home-assistant#153550)
Co-authored-by: Martin Hjelmare <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent f484b6d commit 84f8e57

File tree

2 files changed

+133
-4
lines changed

2 files changed

+133
-4
lines changed

homeassistant/helpers/update_coordinator.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@
4242
class UpdateFailed(HomeAssistantError):
4343
"""Raised when an update has failed."""
4444

45+
def __init__(
46+
self,
47+
*args: Any,
48+
retry_after: float | None = None,
49+
**kwargs: Any,
50+
) -> None:
51+
"""Initialize exception."""
52+
super().__init__(*args, **kwargs)
53+
self.retry_after = retry_after
54+
4555

4656
class BaseDataUpdateCoordinatorProtocol(Protocol):
4757
"""Base protocol type for DataUpdateCoordinator."""
@@ -119,6 +129,7 @@ def __init__(
119129
self._unsub_refresh: CALLBACK_TYPE | None = None
120130
self._unsub_shutdown: CALLBACK_TYPE | None = None
121131
self._request_refresh_task: asyncio.TimerHandle | None = None
132+
self._retry_after: float | None = None
122133
self.last_update_success = True
123134
self.last_exception: Exception | None = None
124135

@@ -250,9 +261,12 @@ def _schedule_refresh(self) -> None:
250261
hass = self.hass
251262
loop = hass.loop
252263

253-
next_refresh = (
254-
int(loop.time()) + self._microsecond + self._update_interval_seconds
255-
)
264+
update_interval = self._update_interval_seconds
265+
if self._retry_after is not None:
266+
update_interval = self._retry_after
267+
self._retry_after = None
268+
269+
next_refresh = int(loop.time()) + self._microsecond + update_interval
256270
self._unsub_refresh = loop.call_at(
257271
next_refresh, self.__wrap_handle_refresh_interval
258272
).cancel
@@ -327,7 +341,9 @@ async def _async_config_entry_first_refresh(self) -> None:
327341
)
328342
if await self.__wrap_async_setup():
329343
await self._async_refresh(
330-
log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True
344+
log_failures=False,
345+
raise_on_auth_failed=True,
346+
raise_on_entry_error=True,
331347
)
332348
if self.last_update_success:
333349
return
@@ -430,6 +446,16 @@ async def _async_refresh( # noqa: C901
430446

431447
except UpdateFailed as err:
432448
self.last_exception = err
449+
# We can only honor a retry_after, after the config entry has been set up
450+
# Basically meaning that the retry after can't be used when coming
451+
# from an async_config_entry_first_refresh
452+
if err.retry_after is not None and not raise_on_entry_error:
453+
self._retry_after = err.retry_after
454+
self.logger.debug(
455+
"Retry after triggered. Scheduling next update in %s second(s)",
456+
err.retry_after,
457+
)
458+
433459
if self.last_update_success:
434460
if log_failures:
435461
self.logger.error("Error fetching %s data: %s", self.name, err)

tests/helpers/test_update_coordinator.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,3 +1117,106 @@ def stop_listen(self) -> None:
11171117

11181118
# Ensure the coordinator is released
11191119
assert weak_ref() is None
1120+
1121+
1122+
@pytest.mark.parametrize(
1123+
("exc", "expected_exception", "message"),
1124+
[
1125+
*KNOWN_ERRORS,
1126+
(Exception(), Exception, "Unknown exception"),
1127+
(
1128+
update_coordinator.UpdateFailed(retry_after=60),
1129+
update_coordinator.UpdateFailed,
1130+
"Error fetching test data",
1131+
),
1132+
],
1133+
)
1134+
@pytest.mark.parametrize(
1135+
"method",
1136+
["update_method", "setup_method"],
1137+
)
1138+
async def test_update_failed_retry_after(
1139+
hass: HomeAssistant,
1140+
exc: Exception,
1141+
expected_exception: type[Exception],
1142+
message: str,
1143+
method: str,
1144+
caplog: pytest.LogCaptureFixture,
1145+
) -> None:
1146+
"""Test async_config_entry_first_refresh raises ConfigEntryNotReady on failure.
1147+
1148+
Verify we do not log the exception since raising ConfigEntryNotReady
1149+
will be caught by config_entries.async_setup which will log it with
1150+
a decreasing level of logging once the first message is logged.
1151+
"""
1152+
entry = MockConfigEntry()
1153+
entry.mock_state(
1154+
hass,
1155+
config_entries.ConfigEntryState.SETUP_IN_PROGRESS,
1156+
)
1157+
crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL, entry)
1158+
setattr(crd, method, AsyncMock(side_effect=exc))
1159+
1160+
with pytest.raises(ConfigEntryNotReady):
1161+
await crd.async_config_entry_first_refresh()
1162+
1163+
assert crd.last_update_success is False
1164+
assert isinstance(crd.last_exception, expected_exception)
1165+
assert message not in caplog.text
1166+
1167+
# Only to check the retry_after wasn't hit
1168+
assert crd._retry_after is None
1169+
1170+
1171+
@pytest.mark.parametrize(
1172+
("exc", "expected_exception", "message"),
1173+
[
1174+
(
1175+
update_coordinator.UpdateFailed(retry_after=60),
1176+
update_coordinator.UpdateFailed,
1177+
"Error fetching test data",
1178+
),
1179+
],
1180+
)
1181+
async def test_refresh_known_errors_retry_after(
1182+
exc: update_coordinator.UpdateFailed,
1183+
expected_exception: type[Exception],
1184+
message: str,
1185+
crd: update_coordinator.DataUpdateCoordinator[int],
1186+
caplog: pytest.LogCaptureFixture,
1187+
hass: HomeAssistant,
1188+
) -> None:
1189+
"""Test raising known errors, this time with retry_after."""
1190+
unsub = crd.async_add_listener(lambda: None)
1191+
1192+
crd.update_method = AsyncMock(side_effect=exc)
1193+
1194+
with (
1195+
patch.object(hass.loop, "time", return_value=1_000.0),
1196+
patch.object(hass.loop, "call_at") as mock_call_at,
1197+
):
1198+
await crd.async_refresh()
1199+
1200+
assert crd.data is None
1201+
assert crd.last_update_success is False
1202+
assert isinstance(crd.last_exception, expected_exception)
1203+
assert message in caplog.text
1204+
1205+
when = mock_call_at.call_args[0][0]
1206+
1207+
expected = 1_000.0 + crd._microsecond + exc.retry_after
1208+
assert abs(when - expected) < 0.005, (when, expected)
1209+
1210+
assert crd._retry_after is None
1211+
1212+
# Next schedule should fall back to regular update_interval
1213+
mock_call_at.reset_mock()
1214+
crd._schedule_refresh()
1215+
when2 = mock_call_at.call_args[0][0]
1216+
expected_cancelled = (
1217+
1_000.0 + crd._microsecond + crd.update_interval.total_seconds()
1218+
)
1219+
assert abs(when2 - expected_cancelled) < 0.005, (when2, expected_cancelled)
1220+
1221+
unsub()
1222+
crd._unschedule_refresh()

0 commit comments

Comments
 (0)