Skip to content

Commit e79ea30

Browse files
committed
fix(addons): harden Supervisor REST failures
1 parent 598dc48 commit e79ea30

2 files changed

Lines changed: 115 additions & 6 deletions

File tree

src/ha_mcp/tools/tools_addons.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
# Maximum response size to return from app (add-on) API calls (50 KB)
6767
_MAX_RESPONSE_SIZE = 50 * 1024
6868

69+
# Supervisor is local to the app network, so connection acquisition should
70+
# remain short even when an app operation needs a multi-minute response budget.
71+
_SUPERVISOR_ACQUIRE_TIMEOUT = 10.0
72+
6973
# Hard safety cap on WebSocket messages collected per call. `message_limit`
7074
# can lower this but never raise it.
7175
_MAX_WS_MESSAGES = 1000
@@ -313,8 +317,11 @@ def _normalize_supervisor_rest_response(
313317
) -> dict[str, Any]:
314318
"""Normalize a direct Supervisor response and retain write ambiguity."""
315319
verb = method.upper()
316-
if response.status_code >= 500 and verb not in {"GET", "HEAD"}:
317-
response_body = response.text.strip()
320+
write_outcome_unknown = verb not in {"GET", "HEAD"} and (
321+
300 <= response.status_code < 400 or response.status_code >= 500
322+
)
323+
if write_outcome_unknown:
324+
response_body = response.text.strip()[:_MAX_RESPONSE_SIZE]
318325
_raise_supervisor_write_outcome_unknown(
319326
ErrorCode.SERVICE_CALL_FAILED,
320327
f"Supervisor API {verb} {endpoint} returned HTTP "
@@ -479,9 +486,15 @@ async def _supervisor_api_call_once(
479486
# optional schema by parsing request.json(), so bodyless POST actions
480487
# must still carry an empty JSON object.
481488
request_kwargs["json"] = data or {}
489+
acquire_timeout = min(wait_timeout, _SUPERVISOR_ACQUIRE_TIMEOUT)
490+
transport_timeout = httpx.Timeout(
491+
wait_timeout,
492+
connect=acquire_timeout,
493+
pool=acquire_timeout,
494+
)
482495
try:
483496
async with make_supervisor_httpx_client(
484-
timeout=wait_timeout,
497+
timeout=transport_timeout,
485498
verify=client.verify_ssl,
486499
) as supervisor_client:
487500
response = await supervisor_client.request(
@@ -492,7 +505,7 @@ async def _supervisor_api_call_once(
492505
except (httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
493506
raise HomeAssistantConnectionError(
494507
f"Supervisor API {method.upper()} {endpoint} could not start before "
495-
f"the {wait_timeout}s timeout: {exc}"
508+
f"the {acquire_timeout}s connection-acquisition timeout: {exc}"
496509
) from exc
497510
except httpx.TimeoutException as exc:
498511
verb = method.upper()
@@ -737,13 +750,19 @@ async def _supervisor_api_call(
737750
raise
738751
except Exception as e:
739752
logger.error(f"Error calling Supervisor API {endpoint}: {e}")
753+
suggestions = None
754+
if isinstance(e, HomeAssistantAPIError) and e.status_code == 404:
755+
suggestions = [
756+
"Check Home Assistant connection and Supervisor availability"
757+
]
740758
exception_to_structured_error(
741759
e,
742760
context={
743761
"endpoint": endpoint,
744762
"operation": f"Supervisor API {endpoint}",
745763
"timeout_seconds": wait_timeout,
746764
},
765+
suggestions=suggestions,
747766
)
748767
return None # unreachable: exception_to_structured_error always raises
749768

tests/src/unit/test_tools_addons.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4126,7 +4126,13 @@ async def test_addon_mode_uses_direct_supervisor_rest(self, monkeypatch):
41264126
"success": True,
41274127
"result": {"addons": [{"slug": "core_mqtt"}]},
41284128
}
4129-
factory.assert_called_once_with(timeout=30.0, verify=False)
4129+
supervisor_timeout = factory.call_args.kwargs["timeout"]
4130+
assert isinstance(supervisor_timeout, httpx.Timeout)
4131+
assert supervisor_timeout.connect == 10.0
4132+
assert supervisor_timeout.read == 30.0
4133+
assert supervisor_timeout.write == 30.0
4134+
assert supervisor_timeout.pool == 10.0
4135+
assert factory.call_args.kwargs["verify"] is False
41304136
direct_client.request.assert_awaited_once_with("GET", "/addons")
41314137
client.send_websocket_message.assert_not_awaited()
41324138

@@ -4278,6 +4284,11 @@ async def test_addon_mode_preserves_direct_http_status_classification(
42784284
assert "refresh" in suggestions
42794285
assert "hassio_api" in suggestions
42804286
assert "hassio_role" in suggestions
4287+
if status_code == 404:
4288+
assert (
4289+
payload["error"]["suggestion"]
4290+
== "Check Home Assistant connection and Supervisor availability"
4291+
)
42814292

42824293
client.send_websocket_message.assert_not_awaited()
42834294

@@ -4370,6 +4381,8 @@ async def test_addon_mode_pre_send_write_failure_is_not_ambiguous(
43704381

43714382
payload = _parse_tool_error(exc_info)
43724383
assert payload["error"]["code"] == expected_code
4384+
if isinstance(transport_error, (httpx.ConnectTimeout, httpx.PoolTimeout)):
4385+
assert "10.0s connection-acquisition timeout" in payload["error"]["message"]
43734386
assert "outcome" not in payload
43744387
direct_client.request.assert_awaited_once()
43754388
client.send_websocket_message.assert_not_awaited()
@@ -4512,6 +4525,77 @@ async def test_addon_mode_write_server_error_reports_unknown_outcome(
45124525
assert payload["status_code"] == response.status_code
45134526
assert payload["response_body"] == response.text
45144527

4528+
@pytest.mark.asyncio
4529+
async def test_addon_mode_write_server_error_caps_response_body(self, monkeypatch):
4530+
"""An unknown-outcome error never returns an unbounded response body."""
4531+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4532+
4533+
monkeypatch.setenv("SUPERVISOR_TOKEN", "test-supervisor-token")
4534+
client = _make_mock_client()
4535+
client.send_websocket_message = AsyncMock()
4536+
direct_client = AsyncMock()
4537+
direct_client.request.return_value = httpx.Response(
4538+
503,
4539+
text="x" * (50 * 1024 + 1),
4540+
)
4541+
context = MagicMock()
4542+
context.__aenter__ = AsyncMock(return_value=direct_client)
4543+
context.__aexit__ = AsyncMock(return_value=False)
4544+
4545+
with (
4546+
patch(
4547+
"ha_mcp.tools.tools_addons.make_supervisor_httpx_client",
4548+
return_value=context,
4549+
create=True,
4550+
),
4551+
pytest.raises(ToolError) as exc_info,
4552+
):
4553+
await _supervisor_api_call(
4554+
client,
4555+
"/addons/core_mosquitto/restart",
4556+
method="POST",
4557+
)
4558+
4559+
payload = _parse_tool_error(exc_info)
4560+
assert payload["response_body"] == "x" * (50 * 1024)
4561+
4562+
@pytest.mark.asyncio
4563+
async def test_addon_mode_write_redirect_reports_unknown_outcome(self, monkeypatch):
4564+
"""A redirect cannot prove whether Supervisor applied the received write."""
4565+
from ha_mcp.tools.tools_addons import _supervisor_api_call
4566+
4567+
monkeypatch.setenv("SUPERVISOR_TOKEN", "test-supervisor-token")
4568+
client = _make_mock_client()
4569+
client.send_websocket_message = AsyncMock()
4570+
direct_client = AsyncMock()
4571+
direct_client.request.return_value = httpx.Response(
4572+
307,
4573+
headers={"location": "/addons/core_mosquitto/restart"},
4574+
)
4575+
context = MagicMock()
4576+
context.__aenter__ = AsyncMock(return_value=direct_client)
4577+
context.__aexit__ = AsyncMock(return_value=False)
4578+
4579+
with (
4580+
patch(
4581+
"ha_mcp.tools.tools_addons.make_supervisor_httpx_client",
4582+
return_value=context,
4583+
create=True,
4584+
),
4585+
pytest.raises(ToolError) as exc_info,
4586+
):
4587+
await _supervisor_api_call(
4588+
client,
4589+
"/addons/core_mosquitto/restart",
4590+
method="POST",
4591+
)
4592+
4593+
payload = _parse_tool_error(exc_info)
4594+
assert payload["error"]["code"] == "SERVICE_CALL_FAILED"
4595+
assert payload["method"] == "POST"
4596+
assert payload["outcome"] == "unknown"
4597+
assert payload["status_code"] == 307
4598+
45154599
@pytest.mark.asyncio
45164600
@pytest.mark.parametrize(
45174601
"response",
@@ -4826,7 +4910,13 @@ async def test_addon_long_timeout_extends_direct_rest_wait(self, monkeypatch):
48264910
timeout=1800,
48274911
)
48284912

4829-
factory.assert_called_once_with(timeout=1815.0, verify=client.verify_ssl)
4913+
supervisor_timeout = factory.call_args.kwargs["timeout"]
4914+
assert isinstance(supervisor_timeout, httpx.Timeout)
4915+
assert supervisor_timeout.connect == 10.0
4916+
assert supervisor_timeout.read == 1815.0
4917+
assert supervisor_timeout.write == 1815.0
4918+
assert supervisor_timeout.pool == 10.0
4919+
assert factory.call_args.kwargs["verify"] is client.verify_ssl
48304920
direct_client.request.assert_awaited_once_with(
48314921
"POST", "/addons/core_mosquitto/update", json={}
48324922
)

0 commit comments

Comments
 (0)