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:
2026-09-23 06:26:29 -04:00
parent ec7c8d4bc2
commit 536caf91e4
5 changed files with 205 additions and 46 deletions
+14 -3
View File
@@ -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
+41 -16
View File
@@ -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(
+46 -9
View File
@@ -367,6 +367,15 @@ class BinarySensorEntity(Entity):
return self._attr_device_class
class UpdateFailed(HomeAssistantError):
"""Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed.
The exception an update pass raises to report a failed fetch: the
coordinator records ``last_update_success = False`` instead of
propagating it out of the refresh.
"""
class DataUpdateCoordinator:
"""Functional stand-in for HA's DataUpdateCoordinator.
@@ -375,8 +384,20 @@ class DataUpdateCoordinator:
``async_add_listener``/``async_update_listeners`` for entity
subscription, ``async_refresh``/``async_config_entry_first_refresh``
driving the subclass's ``_async_update_data``, ``last_update_success``,
and ``data``. Interval scheduling is NOT simulated (no real clock
ticks in tests) — tests drive refreshes directly.
``last_exception``, and ``data``. Interval scheduling is NOT
simulated (no real clock ticks in tests) — tests drive refreshes
directly.
The failure contract mirrors real HA exactly (it is the trap this
harness once hid): an update failure NEVER propagates out of
``async_refresh`` — the coordinator records
``last_update_success = False``, keeps its last good ``data``, and
still notifies listeners so entities re-render unavailable — and
``async_config_entry_first_refresh`` wraps ANY failed pass in
:class:`ConfigEntryNotReady`, with the original error only
surviving as ``__cause__``. A stand-in that re-raised the raw
update error would let setup code key on the original exception
type — a clause real HA can never reach.
"""
def __init__(self, hass, logger, *, name=None, update_interval=None):
@@ -386,6 +407,7 @@ class DataUpdateCoordinator:
self.update_interval = update_interval
self.data = None
self.last_update_success = False
self.last_exception = None
self._listeners = []
def async_add_listener(self, update_callback, context=None):
@@ -405,18 +427,35 @@ class DataUpdateCoordinator:
callback()
async def async_refresh(self):
"""Run one update pass and notify listeners."""
"""Run one update pass and notify listeners.
Mirrors HA: a failed update pass is RECORDED, never raised —
``last_update_success`` goes False, the exception lands on
``last_exception``, and listeners still fire; only cancellation
propagates.
"""
try:
self.data = await self._async_update_data()
self.last_update_success = True
except BaseException:
except Exception as err:
self.last_update_success = False
raise
self.last_exception = err
self.async_update_listeners()
async def async_config_entry_first_refresh(self):
"""First refresh at config-entry setup (alias of refresh)."""
"""First refresh at config-entry setup, per HA's real contract.
HA's real ``async_config_entry_first_refresh`` wraps ANY failed
update pass in ``ConfigEntryNotReady`` — the original error
survives only as ``__cause__`` — so setup code that must
tolerate a failed first refresh keys on ConfigEntryNotReady,
never on the original exception type.
"""
await self.async_refresh()
if not self.last_update_success:
raise ConfigEntryNotReady(
f"error fetching {self.name} data: {self.last_exception}"
) from self.last_exception
async def async_shutdown(self):
"""Release listeners on teardown."""
@@ -426,6 +465,7 @@ class DataUpdateCoordinator:
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
_update_coordinator_mock.UpdateFailed = UpdateFailed
_entity_mock = _ha_mock("homeassistant.helpers.entity")
_entity_mock.DeviceInfo = DeviceInfo
@@ -787,9 +827,6 @@ def coordinator_serves_local_snapshot():
"""
import custom_components.nestquest as nq_mod
import custom_components.nestquest.coordinator as coordinator_module
from custom_components.nestquest.coordinator import (
coordinator_client_from_entry as production_factory,
)
_COORDINATOR_CLIENT_OVERRIDES = (
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
+99 -18
View File
@@ -33,6 +33,7 @@ from custom_components.nestquest.coordinator import (
snapshot_from_api_payload,
)
from custom_components.nestquest.core.snapshot import instance_payload
from homeassistant.helpers.update_coordinator import UpdateFailed
def _today_iso() -> str:
@@ -340,10 +341,12 @@ async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> N
async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> None:
"""A failed fetch (transport failure or non-2xx — both typed) raises
out of the refresh pass: last_update_success goes False (entities
unavailable), data keeps its last good value, and the typed error is
logged, never swallowed."""
"""A failed fetch (transport failure or non-2xx — both typed) is a
FAILED refresh pass: the update pass converts the typed error to
UpdateFailed (the exception HA's coordinator machinery records as a
failed pass, chaining the typed error as ``__cause__``), the refresh
records the failure instead of raising, and data keeps its last
good value."""
error = NestQuestApiError(
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
status=503,
@@ -356,13 +359,61 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N
assert coordinator.data is not None
assert coordinator.last_update_success is True
# The next pass hits the scripted failure: it raises out of the
# refresh, marks the entities unavailable, and keeps the last GOOD
# snapshot as data.
with pytest.raises(NestQuestApiError):
await coordinator.async_refresh()
# The next pass hits the scripted failure: the update pass converts
# the typed error to UpdateFailed, with the typed error chained.
with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo:
await coordinator._async_update_data()
assert isinstance(excinfo.value.__cause__, NestQuestApiError)
# The refresh pass records the failure the HA way — it does NOT
# raise: last_update_success goes False, data keeps the last GOOD
# snapshot, and the failed pass lands on last_exception as the
# converted UpdateFailed with the typed error chained.
await coordinator.async_refresh()
assert coordinator.last_update_success is False
assert coordinator.data.children[0].child_name == "Ada"
assert isinstance(coordinator.last_exception, UpdateFailed)
assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError)
async def test_failed_first_refresh_does_not_crash_setup(
hass, make_entry
) -> None:
"""A failed fetch at the FIRST refresh (at setup) does not crash
setup: the coordinator reports last_update_success=False, the setup
catches the ConfigEntryNotReady that HA's real first refresh raises
for a failed pass, and the platforms still forward so the entities
come up unavailable."""
# The stub fails every snapshot fetch (HTTP 503), including setup's
# first refresh.
client = StubSnapshotClient(
NestQuestApiError(
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
status=503,
)
)
entry = wire_entry_to_registry(
make_entry(
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
),
hass.registry,
)
set_coordinator_client(entry, client)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator.api_client is client
# The failed first pass: the entities' surface stays unavailable.
assert coordinator.last_update_success is False
assert coordinator.data is None
# Setup survived the failed first refresh — the platforms were
# forwarded (the regression the review caught: a first-refresh
# failure used to abort setup before any entity was created).
assert hass.config_entries.forwarded_platforms == [
"sensor",
"binary_sensor",
]
# The unload after the failed-first-refresh setup stays clean.
assert await async_unload_entry(hass, entry) is True
async def test_coordinator_unconfigured_api_does_not_crash_setup(
@@ -386,10 +437,19 @@ async def test_coordinator_unconfigured_api_does_not_crash_setup(
assert isinstance(coordinator.api_client, _UnconfiguredApiClient)
# A real NestQuestApiClient was never built (it would ValueError).
assert not isinstance(coordinator.api_client, NestQuestApiClient)
# The first refresh failed the HA way: the update pass converted
# the stub's typed error to UpdateFailed, the first refresh raised
# ConfigEntryNotReady, and setup tolerated it — platforms forwarded
# so the entities come up unavailable.
assert coordinator.last_update_success is False
assert coordinator.data is None
with pytest.raises(NestQuestApiError, match="not configured"):
await coordinator.async_refresh()
with pytest.raises(UpdateFailed, match="not configured"):
await coordinator._async_update_data()
assert hass.config_entries.forwarded_platforms == [
"sensor",
"binary_sensor",
]
assert await async_unload_entry(hass, entry) is True
async def test_coordinator_configured_token_builds_real_client(
@@ -484,11 +544,23 @@ async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
assert torn_down == [coordinator]
async def test_failed_first_refresh_shuts_coordinator_down(
async def test_setup_failure_after_registration_shuts_coordinator_down(
hass, make_entry, monkeypatch
) -> None:
"""A setup failure after coordinator creation must not leak its
listeners."""
listeners or leave a stale runtime record behind.
The runtime record registers in ``hass.data`` BEFORE the platform
forward, so a forward failure reaches the unwind with the record
already registered. The unwind must shut the coordinator down,
clear ``entry.runtime_data``, AND remove the stale ``hass.data``
entry — otherwise a later unload picks up the stale, already-torn-
down record and re-invokes the already-consumed update-listener
remover, which raises. (A failed first refresh is NOT such a
failure: HA's real first refresh wraps it in ConfigEntryNotReady,
which setup deliberately tolerates so entities come up
unavailable.)
"""
from custom_components.nestquest import async_setup_entry
import custom_components.nestquest.coordinator as coordinator_module
@@ -503,20 +575,29 @@ async def test_failed_first_refresh_shuts_coordinator_down(
_record_shutdown,
)
async def _boom(self):
raise RuntimeError("refresh failed")
async def _boom(entry, platforms):
raise RuntimeError("platform forward failed")
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"_async_update_data",
hass.config_entries,
"async_forward_entry_setups",
_boom,
)
entry = wire_entry_to_registry(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="refresh failed"):
with pytest.raises(RuntimeError, match="platform forward failed"):
await async_setup_entry(hass, entry)
assert shutdown_calls == [entry.entry_id]
assert getattr(entry, "runtime_data", None) is None
# The stale hass.data registration was removed with the runtime
# record: the unload below finds nothing stale, must not raise,
# and must leave no residue behind.
assert await async_unload_entry(hass, entry) is True
assert DOMAIN not in hass.data
assert hass.data == {}
assert hass.registry.size == 0
assert hass.time_change.size == 0
assert entry.runtime_data is None
async def test_snapshot_from_api_payload_empty_household() -> None:
+5
View File
@@ -264,6 +264,11 @@ async def test_due_today_payload_omits_missed_admin_equals_panel(
"the API payload omits missed rows, so the admin payload "
"carries none either (the PWA is the admin surface now)"
)
# Counts still roll the missed instance up as not-completed: the
# counts arrive precomputed from the route's own build, which rolls
# the FULL day (the missed row included) into the numbers.
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
@pytest.mark.parametrize("kind", SENSOR_KINDS)