Skip to content

Commit 1967976

Browse files
kingpanther13claude
andcommitted
fix: address sidelined silent-failure findings + fix connection-drop test
Three findings I previously marked "out of scope" — wrongly, per AGENTS.md § Boy Scout Rule which explicitly forbids that escape hatch: 1. WS dispatcher swallowed handler exceptions without a traceback (``websocket_client.py:403``). The bare ``logger.error(f"... {e}")`` gave operators one line and no stack, so a handler bug (AttributeError on a malformed event, KeyError on schema drift) would silently stop nudging the waiter's ``asyncio.Event`` and the calling waiter would time out reporting "not found." Added ``exc_info=True`` so the traceback lands in logs. The dispatcher itself still swallows so one buggy handler can't kill the WS — but the bug is now visible. 2. ``wait_for_automation_entity_by_unique_id`` emitted a redundant "Automation with unique_id X was not found" warning at the same level as ``_ws_wait_for_condition``'s generic timeout warning. Replaced the redundant warning with a conditional one that fires only when REST sampling was wedged the entire budget, so operators can distinguish "automation truly not published" from "REST channel down." 3. Transient ``HomeAssistantAPIError`` from ``get_states()`` was debug-only with no signal at timeout time. Now tracked in the closure cell and surfaced in the new conditional warning above ("timed out with every REST sample failing; last error: ..."). Also fixes ``test_connection_drop_during_discovery_falls_back_to_rest`` which I wrote against the wrong nudge shape: the original noise event didn't match the discovery filter so the wait timed out at the backstop instead of dropping. Rewritten to mirror ``test_connection_drop_before_wait_loop_falls_back_to_rest`` — drop ``ws_client.is_connected`` inside the post-subscribe sample so the connection-drop branch routes us straight to ``_legacy_poll_until``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c944597 commit 1967976

3 files changed

Lines changed: 65 additions & 42 deletions

File tree

src/ha_mcp/client/websocket_client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,18 @@ async def _handle_event_message(
400400
try:
401401
await handler(data["event"])
402402
except Exception as e:
403-
logger.error(f"Error in event handler: {e}")
403+
# ``exc_info=True`` so handler bugs (AttributeError /
404+
# KeyError / TypeError from schema-drift on the
405+
# incoming event payload) leave a traceback rather
406+
# than a one-line obscured error. Without this the
407+
# dispatch loop keeps a single buggy handler from
408+
# killing the WS, but the bug itself becomes
409+
# invisible — handlers wired to ``asyncio.Event``
410+
# nudges (see ``util_helpers._ws_wait_for_condition``)
411+
# silently stop nudging and the calling waiter times
412+
# out reporting "not found." #1395 silent-failure
413+
# audit.
414+
logger.error("Error in event handler: %s", e, exc_info=True)
404415

405416
def _ensure_send_lock(self) -> None:
406417
"""Ensure the send lock belongs to the current event loop."""

src/ha_mcp/tools/util_helpers.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,20 +1131,27 @@ async def wait_for_automation_entity_by_unique_id(
11311131
The discovered entity_id (e.g. ``"automation.morning_routine"``)
11321132
or ``None`` on timeout.
11331133
"""
1134-
# Mutable closure cell: when an event carries the match, the filter
1135-
# stashes the discovered entity_id so sample() can short-circuit the
1136-
# full get_states() scan on the next loop tick.
1137-
captured: dict[str, str | None] = {"entity_id": None}
1134+
# Mutable closure cells: ``entity_id`` stashes the discovered
1135+
# entity_id when the filter sees a matching event (sample() then
1136+
# short-circuits the full get_states() scan). ``last_api_error``
1137+
# tracks the most recent transient API failure during sampling so
1138+
# the final timeout warning can distinguish "automation truly not
1139+
# found" from "REST channel wedged the whole budget."
1140+
captured: dict[str, str | None] = {"entity_id": None, "last_api_error": None}
11381141

11391142
async def sample() -> str | None:
11401143
if captured["entity_id"] is not None:
11411144
return captured["entity_id"]
11421145
try:
11431146
states = await client.get_states()
11441147
except HomeAssistantAPIError as e:
1148+
# Debug-level here is intentional — the waiter retries on
1149+
# transient errors. The wedged-channel signal goes in the
1150+
# final timeout warning via ``captured["last_api_error"]``.
11451151
logger.debug(
11461152
f"API error sampling get_states() for unique_id {unique_id}: {e}"
11471153
)
1154+
captured["last_api_error"] = str(e)
11481155
return None
11491156
for state in states:
11501157
entity_id = state.get("entity_id")
@@ -1207,9 +1214,18 @@ def event_filter(event: dict[str, Any]) -> bool:
12071214
)
12081215
if isinstance(result, str):
12091216
return result
1210-
logger.warning(
1211-
f"Automation with unique_id {unique_id} was not found in HA state after creation"
1212-
)
1217+
# `_ws_wait_for_condition` / `_legacy_poll_until` already logged the
1218+
# generic "timed out" warning before returning None; just surface the
1219+
# discovery-specific signal when REST sampling was wedged the whole
1220+
# budget so operators can distinguish "automation never published"
1221+
# from "REST channel down."
1222+
if captured["last_api_error"] is not None:
1223+
logger.warning(
1224+
"Automation discovery for unique_id %s timed out with every "
1225+
"REST sample failing; last error: %s",
1226+
unique_id,
1227+
captured["last_api_error"],
1228+
)
12131229
return None
12141230

12151231

tests/src/unit/test_wait_helpers.py

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,46 +1022,42 @@ async def fire_after_subscribe():
10221022
async def test_connection_drop_during_discovery_falls_back_to_rest(
10231023
self, ws_client, mock_client
10241024
):
1025-
"""If the WS drops mid-wait during a discovery wait, the helper
1025+
"""If the WS drops while a discovery wait is in flight, the helper
10261026
must fall back to REST polling using the discovery-shaped
1027-
``get_states()`` sample — not the entity-id-shaped sample used by
1028-
the other waiters. Mirrors ``TestWsPathConnectionDrop`` coverage
1029-
for the entity_id wait path; this pins the discovery path against
1030-
a regression that wires the wrong fallback sample."""
1031-
# Post-subscribe sample returns nothing; after the noise event
1032-
# nudges the loop, the WS goes dead and the helper falls back to
1033-
# REST polling, which then resolves the match.
1034-
mock_client.get_states = AsyncMock(
1035-
side_effect=[
1036-
[], # post-subscribe sample: empty
1037-
[], # nudge re-sample after noise event: empty (WS now dead)
1038-
[ # REST fallback path resolves
1039-
{
1040-
"entity_id": "automation.found_via_rest",
1041-
"attributes": {"id": "uid_drop"},
1042-
}
1043-
],
1027+
``get_states()`` sample — not the entity-id-shaped
1028+
``get_entity_state()`` used by sibling waiters. Mirrors
1029+
``TestWsPathConnectionDrop::test_connection_drop_before_wait_loop_falls_back_to_rest``
1030+
for the discovery path; pins against a regression that wires
1031+
the wrong fallback sample."""
1032+
call_count = {"n": 0}
1033+
1034+
async def get_states_dropping_ws():
1035+
call_count["n"] += 1
1036+
if call_count["n"] == 1:
1037+
# Post-subscribe sample: empty AND drops the WS so the
1038+
# connection-drop-before-wait-loop branch runs (the
1039+
# ``is_connected`` check between sample and wait loop
1040+
# routes us to ``_legacy_poll_until`` for the remaining
1041+
# budget — no backstop interval wasted).
1042+
ws_client.is_connected = False
1043+
return []
1044+
# REST fallback calls find the matching automation.
1045+
return [
1046+
{
1047+
"entity_id": "automation.found_via_rest",
1048+
"attributes": {"id": "uid_drop"},
1049+
}
10441050
]
1045-
)
10461051

1047-
async def drop_after_noise():
1048-
await asyncio.sleep(0.05)
1049-
# Fire a noise event so the wait loop wakes and re-samples,
1050-
# THEN drop the WS so the next loop tick takes the REST
1051-
# fallback path.
1052-
await ws_client.fire_state_changed(
1053-
"automation.unrelated",
1054-
new_state={"attributes": {"id": "uid_other"}},
1055-
)
1056-
ws_client.is_connected = False
1052+
mock_client.get_states = AsyncMock(side_effect=get_states_dropping_ws)
10571053

1058-
drop_task = asyncio.create_task(drop_after_noise())
10591054
result = await wait_for_automation_entity_by_unique_id(
10601055
mock_client, "uid_drop", timeout=2.0, poll_interval=0.05
10611056
)
1062-
await drop_task
10631057

10641058
assert result == "automation.found_via_rest"
1065-
# REST fallback was actually exercised — at least one
1066-
# ``get_states()`` call past the initial post-subscribe sample.
1067-
assert mock_client.get_states.call_count >= 2
1059+
# At least one extra REST sample happened after the drop —
1060+
# proves the fallback path actually ran the discovery sample.
1061+
assert call_count["n"] >= 2
1062+
# Cleanup of the subscription we did establish still ran.
1063+
assert len(ws_client.unsubscribed) == 1

0 commit comments

Comments
 (0)