Skip to content

Commit 5103da7

Browse files
Patch76Patch76
andauthored
refactor: extract shared Supervisor httpx client helper (#1130) (#1203)
* refactor: extract shared Supervisor httpx client helper (#1130) Three direct-Supervisor httpx call sites all built fresh AsyncClients with the same boilerplate (base URL http://supervisor, Authorization: Bearer $SUPERVISOR_TOKEN). Move that into a single factory in client/supervisor_client.py and have the call sites pass relative paths instead of full URLs: - client/rest_client.py:_supervisor_logs_get - tools/tools_bug_report.py:_fetch_addon_logs - settings_ui.py:_restart_addon Per-call clients (vs. a singleton) preserved — these endpoints are low-frequency, connection-pool reuse is negligible, and singleton lifecycle adds shutdown/reload coupling that the issue's #1126 G2 review already declined for the same reason. verify and timeout stay caller-supplied so each site keeps its existing settings-source choice (instance snapshot vs. live read). Token is read from env in the helper at construction time, matching the original sites; the absent-token policy stays at the call site (rich exception, silent empty string, or 400 JSONResponse — these don't share a common shape and the helper shouldn't dictate one). Tests: new tests/src/unit/test_supervisor_client.py covers the factory contract (base_url, env override, Bearer header, timeout/verify forwarding, absent-token graceful degradation). Two existing tests in test_tools_utility_supervisor_logs.py updated to the new contract: URL passed to .get() is now relative; Authorization header asserted on the constructor kwargs instead of the per-call kwargs. Closes #1130. * fix: address round-2 review findings on #1130 - Raise RuntimeError on absent/empty SUPERVISOR_TOKEN at the factory so the malformed Bearer header never reaches Supervisor. - Add wire-shape unit tests for _fetch_addon_logs and _restart_addon asserting relative URLs, ctor-set Authorization, and no per-call Authorization kwarg. - Add a header-layering test pinning per-call Accept overlay on the ctor-set Authorization. - Drop ephemeral PR/issue refs from code and test comments per AGENTS.md. - Broaden the verify parameter type to httpx's full surface, add an env-read-at-construction Note to the docstring, dedup the token-handling paragraph. - Sibling: test_config_toggles_section_renders_in_templates now delenv's SUPERVISOR_TOKEN so the SimpleNamespace fake (missing verify_ssl) doesn't hit the addon-logs path on containers with the env var set. --------- Co-authored-by: Patch76 <mkglasmoor@gmail.com>
1 parent e5a1365 commit 5103da7

8 files changed

Lines changed: 383 additions & 39 deletions

File tree

src/ha_mcp/client/rest_client.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from .._version import get_supervisor_base_url, is_running_in_addon
1515
from ..config import get_global_settings
16+
from .supervisor_client import make_supervisor_httpx_client
1617

1718

1819
def _is_ssl_error(exc: BaseException) -> bool:
@@ -555,23 +556,21 @@ async def _supervisor_logs_get(self, path: str) -> str:
555556
"(addon-mode gate fired but SUPERVISOR_TOKEN env var not set)"
556557
)
557558

558-
url = f"{get_supervisor_base_url()}/{path}/logs"
559-
logger.debug("Fetching %s via Supervisor direct", url)
559+
relative_path = f"/{path}/logs"
560+
logger.debug(
561+
"Fetching %s%s via Supervisor direct",
562+
get_supervisor_base_url(),
563+
relative_path,
564+
)
560565

561566
try:
562-
async with httpx.AsyncClient(
567+
async with make_supervisor_httpx_client(
563568
timeout=httpx.Timeout(self.timeout),
564-
# `verify` is a no-op for plain http://supervisor, but kept
565-
# for symmetry with the other two direct-Supervisor httpx
566-
# clients (#1128 establishes the 3-site convention).
567569
verify=self.verify_ssl,
568570
) as client:
569571
response = await client.get(
570-
url,
571-
headers={
572-
"Authorization": f"Bearer {token}",
573-
"Accept": "text/plain",
574-
},
572+
relative_path,
573+
headers={"Accept": "text/plain"},
575574
)
576575
except httpx.TimeoutException as e:
577576
raise HomeAssistantConnectionError(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Shared factory for direct-Supervisor httpx clients.
2+
3+
Three call sites in the codebase talk directly to the Home Assistant
4+
Supervisor REST API at ``http://supervisor`` rather than through
5+
``HomeAssistantClient.httpx_client`` (which is bound to HA Core, not the
6+
Supervisor — different base URL, different token, different role gate):
7+
8+
- :meth:`ha_mcp.client.rest_client.HomeAssistantClient._supervisor_logs_get`
9+
— fetches addon and system-service logs
10+
- :func:`ha_mcp.tools.tools_bug_report._fetch_addon_logs` — bundles ha-mcp's
11+
own addon logs into a bug-report payload
12+
- :func:`ha_mcp.settings_ui._restart_addon` — POSTs ``/addons/self/restart``
13+
from the settings UI
14+
15+
All three share the same boilerplate (base URL, ``Authorization: Bearer
16+
${SUPERVISOR_TOKEN}`` header), so this module supplies a single factory and
17+
keeps the three sites consistent.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import os
23+
import ssl
24+
25+
import httpx
26+
27+
from .._version import get_supervisor_base_url
28+
29+
__all__ = ["make_supervisor_httpx_client"]
30+
31+
32+
def make_supervisor_httpx_client(
33+
*,
34+
timeout: float | httpx.Timeout,
35+
verify: bool | str | ssl.SSLContext,
36+
) -> httpx.AsyncClient:
37+
"""Construct an ``httpx.AsyncClient`` pre-configured for the Supervisor REST API.
38+
39+
Args:
40+
timeout: Per-request timeout. Accepts either a plain ``float``
41+
(seconds, applied to all phases) or a full :class:`httpx.Timeout`
42+
for finer-grained control.
43+
verify: TLS verify policy. A no-op for the default
44+
``http://supervisor`` base URL (plain HTTP — no TLS to verify),
45+
but kept as a parameter because :func:`get_supervisor_base_url`
46+
honours ``SUPERVISOR_BASE_URL`` env-var overrides that may be
47+
HTTPS in non-add-on test rigs. The full httpx ``verify`` surface
48+
(``bool``, CA-bundle path, or :class:`ssl.SSLContext`) is
49+
accepted and forwarded verbatim.
50+
51+
Returns:
52+
A new :class:`httpx.AsyncClient` bound to the Supervisor base URL
53+
with ``Authorization: Bearer ${SUPERVISOR_TOKEN}`` preset. Callers
54+
pass relative paths (``/addons/self/logs``) to ``client.get/post``;
55+
``base_url`` joins them onto the Supervisor host.
56+
57+
Raises:
58+
RuntimeError: ``SUPERVISOR_TOKEN`` is unset or empty in the
59+
environment. Each call site has its own absent-token policy
60+
(a rich :class:`HomeAssistantAuthError`, a silent ``""``
61+
return, or a 400 ``JSONResponse``) that does not share a
62+
common shape, so the factory cannot translate. Detecting the
63+
absence at construction time prevents a malformed
64+
``Authorization: Bearer `` header from being read as a token
65+
rejection by Supervisor, which would mask the missing-env-var
66+
root cause.
67+
68+
Note:
69+
``SUPERVISOR_TOKEN`` is read from env at construction time and
70+
baked into the constructed client's ``Authorization`` header.
71+
Reusing a single client across token rotations would not pick up
72+
the new value — short-lived ``async with`` callers are unaffected,
73+
but a future long-lived caller would need to discard and re-create.
74+
"""
75+
token = os.environ.get("SUPERVISOR_TOKEN", "")
76+
if not token:
77+
raise RuntimeError(
78+
"SUPERVISOR_TOKEN is not set; "
79+
"make_supervisor_httpx_client cannot construct an "
80+
"authenticated client. Callers must verify the token is "
81+
"present before invoking the factory."
82+
)
83+
return httpx.AsyncClient(
84+
base_url=get_supervisor_base_url(),
85+
timeout=timeout,
86+
verify=verify,
87+
headers={"Authorization": f"Bearer {token}"},
88+
)

src/ha_mcp/settings_ui.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
from starlette.requests import Request
2020
from starlette.responses import HTMLResponse, JSONResponse
2121

22-
from ._version import get_supervisor_base_url, is_running_in_addon
22+
from ._version import is_running_in_addon
23+
from .client.supervisor_client import make_supervisor_httpx_client
2324
from .errors import ErrorCode, create_error_response
2425
from .transforms import DEFAULT_PINNED_TOOLS
2526
from .utils.data_paths import get_data_dir
@@ -893,8 +894,7 @@ async def _save_tools(request: Request) -> JSONResponse:
893894
)
894895

895896
async def _restart_addon(_: Request) -> JSONResponse:
896-
token = os.environ.get("SUPERVISOR_TOKEN")
897-
if not token:
897+
if not os.environ.get("SUPERVISOR_TOKEN"):
898898
return JSONResponse(
899899
create_error_response(
900900
ErrorCode.CONFIG_VALIDATION_FAILED,
@@ -906,13 +906,10 @@ async def _restart_addon(_: Request) -> JSONResponse:
906906
# Short timeout — the supervisor kills our process during restart so
907907
# the connection will drop. A connection drop is actually success.
908908
try:
909-
async with httpx.AsyncClient(
909+
async with make_supervisor_httpx_client(
910910
timeout=5.0, verify=server.settings.verify_ssl
911911
) as client:
912-
resp = await client.post(
913-
f"{get_supervisor_base_url()}/addons/self/restart",
914-
headers={"Authorization": f"Bearer {token}"},
915-
)
912+
resp = await client.post("/addons/self/restart")
916913
except (httpx.ReadError, httpx.RemoteProtocolError):
917914
# Connection dropped mid-request — restart is happening.
918915
# `ConnectError` is deliberately NOT in this tuple: it fires

src/ha_mcp/tools/tools_bug_report.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from ha_mcp import __version__
2222

23-
from .._version import get_supervisor_base_url
23+
from ..client.supervisor_client import make_supervisor_httpx_client
2424
from ..config import Settings, get_global_settings
2525
from ..utils.usage_logger import (
2626
AVG_LOG_ENTRIES_PER_TOOL,
@@ -349,18 +349,14 @@ async def _fetch_addon_logs() -> str:
349349
"""
350350
# Redundant with the caller's `install_method == "addon"` gate, but kept
351351
# as a defensive guard for any direct callers added later.
352-
token = os.environ.get("SUPERVISOR_TOKEN", "")
353-
if not token:
352+
if not os.environ.get("SUPERVISOR_TOKEN"):
354353
return ""
355354

356355
try:
357-
async with httpx.AsyncClient(
356+
async with make_supervisor_httpx_client(
358357
timeout=10.0, verify=get_global_settings().verify_ssl
359358
) as http_client:
360-
resp = await http_client.get(
361-
f"{get_supervisor_base_url()}/addons/self/logs",
362-
headers={"Authorization": f"Bearer {token}"},
363-
)
359+
resp = await http_client.get("/addons/self/logs")
364360
if resp.status_code != 200:
365361
logger.info("Addon log fetch returned HTTP %s", resp.status_code)
366362
return ""

tests/src/unit/test_settings_ui.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,3 +598,36 @@ async def test_supervisor_4xx_returns_502(self, monkeypatch):
598598
assert resp.status_code == 502
599599
body = json.loads(resp.body)
600600
assert body["success"] is False
601+
602+
@pytest.mark.asyncio
603+
async def test_posts_relative_url_with_ctor_authorization(self, monkeypatch):
604+
"""Post-refactor wire shape: relative path on ``.post()``, with
605+
``Authorization`` assembled in the constructor kwargs rather than
606+
per-call. Mirrors the parallel assertions in
607+
``test_tools_utility_supervisor_logs.py`` so a regression that
608+
hard-codes the absolute URL or moves the Bearer header back to
609+
per-call kwargs is caught on the restart-handler branch too.
610+
"""
611+
restart = self._capture_handler(monkeypatch, with_token=True)
612+
request = MagicMock()
613+
614+
response = MagicMock()
615+
response.status_code = 200
616+
mock_client = MagicMock()
617+
mock_client.post = AsyncMock(return_value=response)
618+
cm = MagicMock()
619+
cm.__aenter__ = AsyncMock(return_value=mock_client)
620+
cm.__aexit__ = AsyncMock(return_value=None)
621+
622+
client_class = MagicMock(return_value=cm)
623+
with patch("ha_mcp.settings_ui.httpx.AsyncClient", client_class):
624+
await restart(request)
625+
626+
mock_client.post.assert_awaited_once()
627+
args, kwargs = mock_client.post.call_args
628+
assert args[0] == "/addons/self/restart"
629+
assert "Authorization" not in kwargs.get("headers", {})
630+
631+
ctor_kwargs = client_class.call_args.kwargs
632+
assert ctor_kwargs["base_url"] == "http://supervisor"
633+
assert ctor_kwargs["headers"]["Authorization"] == "Bearer fake-supervisor-token"

0 commit comments

Comments
 (0)