Fix review findings: first-refresh handling, stale runtime record, P3s
P1: the first-refresh error handling never fired against real Home Assistant. Real DataUpdateCoordinator.async_config_entry_first_refresh converts ANY refresh failure into ConfigEntryNotReady (the original exception survives only as __cause__), so `except NestQuestApiError` could never match: the failure fell through to the outer except block, unwound listeners, closed the DB and re-raised, and async_forward_entry_setups never ran — no entities at all. Two-sided fix: _async_update_data now converts every typed NestQuestApiError (unconfigured stub, transport failure, non-2xx) into homeassistant.helpers.update_coordinator.UpdateFailed — the exception HA's coordinator machinery records as a failed pass (last_update_success=False, data=None) — and setup catches ConfigEntryNotReady around the first refresh instead, so an unconfigured API or failed fetch keeps setup alive with unavailable entities while everything else still unwinds. The conftest DataUpdateCoordinator stand-in now reproduces HA's real contract (async_refresh records a failure instead of raising; first_refresh wraps it in ConfigEntryNotReady) and gains an UpdateFailed stand-in, so this class of bug is caught by the suite — verified by reverting the catch, which fails both unconfigured-API and failed-fetch tests. P1: a stale hass.data[DOMAIN][entry.entry_id] survived a failed setup. The runtime record registers in hass.data before the first refresh and platform forward, but the except cleanup only cleared entry.runtime_data; after any failure in that window a later unload picked up the stale, already-torn-down record and re-invoked the already-consumed update-listener remover (ValueError). The except block now also pops the hass.data entry, and test_setup_failure_after_registration_shuts_coordinator_down (reworked from the fiction-based first-refresh trigger: a failed first refresh is tolerated setup, not an unwind) proves a failed setup followed by an unload does not raise and leaves no residue. P3: dropped the function-local re-import of NestQuestApiClient in coordinator_client_from_entry (module-level import already covers it), and the unused coordinator_client_from_entry alias in the conftest fixture. P3: test_due_today_payload_omits_missed_admin_equals_panel dropped the missed-instance count assertions in the Feature 18 rewrite; restored quests_completed_today == 0 / quests_remaining_today == 1 so the missed-derivation + counts pairing stays verified (the counts arrive precomputed and still roll the full day).
This commit is contained in:
@@ -16,7 +16,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .api_client import NestQuestApiError
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
@@ -526,10 +525,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# (unconfigured token, API service down) does NOT crash setup:
|
||||
# the coordinator reports last_update_success=False, entities
|
||||
# come up unavailable, and HA's standard retry cycle applies
|
||||
# once the API is reachable. Any non-API error still raises.
|
||||
# once the API is reachable. HA's real
|
||||
# ``async_config_entry_first_refresh`` wraps ANY failed update
|
||||
# pass in ConfigEntryNotReady — the original error survives
|
||||
# only as ``__cause__``, never as the raised exception — so the
|
||||
# catch keys on ConfigEntryNotReady, NOT on the typed API
|
||||
# error. Everything else (a platform-forward failure,
|
||||
# cancellation) still unwinds through the except block below.
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except NestQuestApiError:
|
||||
except ConfigEntryNotReady:
|
||||
pass
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
@@ -564,6 +569,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# closes the already-closed database through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
# The runtime record was ALSO registered in hass.data before
|
||||
# the first refresh: remove it here on ANY failure, or a later
|
||||
# unload picks up the stale, already-torn-down record
|
||||
# (hass.data wins over entry.runtime_data there) and
|
||||
# re-invokes the already-consumed update-listener remover.
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
await database.close()
|
||||
raise
|
||||
assert remove_update_listener is not None
|
||||
|
||||
@@ -32,11 +32,17 @@ Failure handling: constructing the client fails fast (ValueError) when
|
||||
the entry has no panel token configured, and a fetch failure raises the
|
||||
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
|
||||
integration setup: the constructor logs a clear message and hands back
|
||||
a client-shaped stub whose refresh raises the same typed error, so the
|
||||
coordinator's refresh pass marks the entities unavailable
|
||||
(``last_update_success = False``, ``data = None``) — exactly how a HA
|
||||
``DataUpdateCoordinator`` surfaces a failed update. The typed error is
|
||||
never swallowed silently: it is logged with its message and chained.
|
||||
a client-shaped stub whose refresh raises the same typed error, and
|
||||
``_async_update_data`` converts every typed error into
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||
(``last_update_success = False``, ``data = None``), so the entities
|
||||
come up unavailable. The typed error is never swallowed silently: it
|
||||
is logged with its message and chained into the UpdateFailed. (The
|
||||
conversion matters at setup: HA's real
|
||||
``async_config_entry_first_refresh`` wraps any failed pass in
|
||||
``ConfigEntryNotReady``, so a setup that keys on the original
|
||||
exception type would never match it.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -45,7 +51,10 @@ import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
|
||||
from .const import (
|
||||
@@ -150,9 +159,11 @@ class _UnconfiguredApiClient:
|
||||
yet must not crash integration setup, so the coordinator swaps in
|
||||
this stub: every refresh raises the SAME typed
|
||||
:class:`~.api_client.NestQuestApiError` a transport failure would,
|
||||
carrying a clear "API not configured" message, so the coordinator
|
||||
marks the entities unavailable and the HA standard retry cycle
|
||||
applies. The error is logged, never silent.
|
||||
carrying a clear "API not configured" message;
|
||||
:meth:`NestQuestCoordinator._async_update_data` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
|
||||
the coordinator marks the entities unavailable and the HA standard
|
||||
retry cycle applies. The error is logged, never silent.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
@@ -171,10 +182,15 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
|
||||
API client, reconstructed into the core snapshot dataclasses every
|
||||
entity reads. A failed pass (unconfigured API, transport failure,
|
||||
non-2xx response) raises the typed error out of
|
||||
``_async_update_data``, so the coordinator's ``data`` keeps its last
|
||||
good value while ``last_update_success`` goes False — HA marks the
|
||||
entities unavailable until a later pass succeeds.
|
||||
non-2xx response) raises the typed error out of the client;
|
||||
``_async_update_data`` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
|
||||
exception HA's coordinator machinery records as a failed pass: the
|
||||
coordinator's ``data`` keeps its last good value while
|
||||
``last_update_success`` goes False — HA marks the entities
|
||||
unavailable until a later pass succeeds. An unexpected
|
||||
non-typed error still propagates out of ``_async_update_data``
|
||||
and is logged (with its traceback) by HA's machinery, the same way.
|
||||
|
||||
Forced refreshes (the service paths pushing an immediate update
|
||||
after a completion) are SERIALIZED per entry: overlapping
|
||||
@@ -218,13 +234,24 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
await super().async_refresh()
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||
|
||||
A typed API failure (unconfigured token stub, transport
|
||||
failure, non-2xx response) is converted to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
|
||||
— the exception HA's coordinator machinery records as a failed
|
||||
pass (``last_update_success = False``, ``data`` unchanged) so
|
||||
entities go unavailable and the setup-time first refresh
|
||||
arrives as ``ConfigEntryNotReady`` — with the typed error
|
||||
logged and chained. Anything else still propagates.
|
||||
"""
|
||||
try:
|
||||
payload = await self.api_client.get_snapshot()
|
||||
except NestQuestApiError as err:
|
||||
LOGGER.error(
|
||||
"NestQuest coordinator refresh failed: %s", err
|
||||
)
|
||||
raise
|
||||
raise UpdateFailed(str(err)) from err
|
||||
return snapshot_from_api_payload(payload)
|
||||
|
||||
|
||||
@@ -247,8 +274,6 @@ def coordinator_client_from_entry(
|
||||
inject a stub transport without importing aiohttp — the mock-only
|
||||
test harness has no ``homeassistant.helpers.aiohttp_client``.
|
||||
"""
|
||||
from custom_components.nestquest.api_client import NestQuestApiClient
|
||||
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
LOGGER.warning(
|
||||
|
||||
Reference in New Issue
Block a user