Skip to content

Commit 5779b56

Browse files
authored
Optimize API connection handling by removing redundant port checks (home-assistant#6212)
* Simplify ensure_access_token Make the caller of ensure_access_token responsible for connection error handling. This is especially useful for API connection checks, as it avoids an extra call to the API (if we fail to connect when refreshing the token there is no point in calling the API to check if it is up). Document the change in the docstring. Also avoid the overhead of creating a Job object. We can simply use an asyncio.Lock() to ensure only one coroutine is refreshing the token at a time. This also avoids Job interference in Exception handling. * Remove check_port from API checks Remove check_port usage from Home Assistant API connection checks. Simply rely on errors raised from actual connection attempts. During a Supervisor startup when Home Assistant Core is running (e.g. after a Supervisor update) we make about 10 successful API checks. The old code path did a port check and then a connection check, causing two socket creation. The new code without the separate port check safes 10 socket creations per startup (the aiohttp connections are reused, hence do not cause only one socket creation). * Log API exceptions on call site Since make_request is no longer logging API exceptions on its own, we need to log them where we call make_request. This approach gives the user more context about what Supervisor was trying to do when the error happened. * Avoid unnecessary nesting * Improve error when ingress panel update fails * Add comment about fast path
1 parent 3c5f492 commit 5779b56

File tree

9 files changed

+96
-63
lines changed

9 files changed

+96
-63
lines changed

supervisor/addons/addon.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@
7272
AddonsJobError,
7373
ConfigurationFileError,
7474
DockerError,
75-
HomeAssistantAPIError,
7675
HostAppArmorError,
7776
)
7877
from ..hardware.data import Device
@@ -842,8 +841,7 @@ def cleanup_config_and_audio():
842841
# Cleanup Ingress panel from sidebar
843842
if self.ingress_panel:
844843
self.ingress_panel = False
845-
with suppress(HomeAssistantAPIError):
846-
await self.sys_ingress.update_hass_panel(self)
844+
await self.sys_ingress.update_hass_panel(self)
847845

848846
# Cleanup Ingress dynamic port assignment
849847
need_ingress_token_cleanup = False

supervisor/addons/manager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
CoreDNSError,
2121
DockerError,
2222
HassioError,
23-
HomeAssistantAPIError,
2423
)
2524
from ..jobs.decorator import Job, JobCondition
2625
from ..resolution.const import ContextType, IssueType, SuggestionType
@@ -351,8 +350,7 @@ async def restore(
351350
# Update ingress
352351
if had_ingress != addon.ingress_panel:
353352
await self.sys_ingress.reload()
354-
with suppress(HomeAssistantAPIError):
355-
await self.sys_ingress.update_hass_panel(addon)
353+
await self.sys_ingress.update_hass_panel(addon)
356354

357355
return wait_for_start
358356

supervisor/api/proxy.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ async def _api_client(
7777
yield resp
7878
return
7979

80-
except HomeAssistantAuthError:
81-
_LOGGER.error("Authenticate error on API for request %s", path)
82-
except HomeAssistantAPIError:
83-
_LOGGER.error("Error on API for request %s", path)
80+
except HomeAssistantAuthError as err:
81+
_LOGGER.error("Authenticate error on API for request %s: %s", path, err)
82+
except HomeAssistantAPIError as err:
83+
_LOGGER.error("Error on API for request %s: %s", path, err)
8484
except aiohttp.ClientError as err:
8585
_LOGGER.error("Client error on API %s request %s", path, err)
8686
except TimeoutError:

supervisor/auth.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ async def _backend_login(self, addon: Addon, username: str, password: str) -> bo
132132
_LOGGER.warning("Unauthorized login for '%s'", username)
133133
await self._dismatch_cache(username, password)
134134
return False
135-
except HomeAssistantAPIError:
136-
_LOGGER.error("Can't request auth on Home Assistant!")
135+
except HomeAssistantAPIError as err:
136+
_LOGGER.error("Can't request auth on Home Assistant: %s", err)
137137
finally:
138138
self._running.pop(username, None)
139139

@@ -152,8 +152,8 @@ async def change_password(self, username: str, password: str) -> None:
152152
return
153153

154154
_LOGGER.warning("The user '%s' is not registered", username)
155-
except HomeAssistantAPIError:
156-
_LOGGER.error("Can't request password reset on Home Assistant!")
155+
except HomeAssistantAPIError as err:
156+
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
157157

158158
raise AuthPasswordResetError()
159159

supervisor/discovery/__init__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
from contextlib import suppress
65
import logging
76
from typing import TYPE_CHECKING, Any
87
from uuid import uuid4
@@ -119,7 +118,7 @@ async def _push_discovery(self, message: Message, command: str) -> None:
119118
data = attr.asdict(message)
120119
data.pop(ATTR_CONFIG)
121120

122-
with suppress(HomeAssistantAPIError):
121+
try:
123122
async with self.sys_homeassistant.api.make_request(
124123
command,
125124
f"api/hassio_push/discovery/{message.uuid}",
@@ -128,5 +127,5 @@ async def _push_discovery(self, message: Message, command: str) -> None:
128127
):
129128
_LOGGER.info("Discovery %s message send", message.uuid)
130129
return
131-
132-
_LOGGER.warning("Discovery %s message fail", message.uuid)
130+
except HomeAssistantAPIError as err:
131+
_LOGGER.error("Discovery %s message failed: %s", message.uuid, err)

supervisor/homeassistant/api.py

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
from collections.abc import AsyncIterator
5-
from contextlib import asynccontextmanager, suppress
5+
from contextlib import asynccontextmanager
66
from dataclasses import dataclass
77
from datetime import UTC, datetime, timedelta
88
import logging
@@ -15,9 +15,7 @@
1515

1616
from ..coresys import CoreSys, CoreSysAttributes
1717
from ..exceptions import HomeAssistantAPIError, HomeAssistantAuthError
18-
from ..jobs.const import JobConcurrency
19-
from ..jobs.decorator import Job
20-
from ..utils import check_port, version_is_new_enough
18+
from ..utils import version_is_new_enough
2119
from .const import LANDINGPAGE
2220

2321
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -43,22 +41,35 @@ def __init__(self, coresys: CoreSys):
4341
# We don't persist access tokens. Instead we fetch new ones when needed
4442
self.access_token: str | None = None
4543
self._access_token_expires: datetime | None = None
44+
self._token_lock: asyncio.Lock = asyncio.Lock()
4645

47-
@Job(
48-
name="home_assistant_api_ensure_access_token",
49-
internal=True,
50-
concurrency=JobConcurrency.QUEUE,
51-
)
5246
async def ensure_access_token(self) -> None:
53-
"""Ensure there is an access token."""
47+
"""Ensure there is a valid access token.
48+
49+
Raises:
50+
HomeAssistantAuthError: When we cannot get a valid token
51+
aiohttp.ClientError: On network or connection errors
52+
TimeoutError: On request timeouts
53+
54+
"""
55+
# Fast path check without lock (avoid unnecessary locking
56+
# for the majority of calls).
5457
if (
5558
self.access_token
5659
and self._access_token_expires
5760
and self._access_token_expires > datetime.now(tz=UTC)
5861
):
5962
return
6063

61-
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
64+
async with self._token_lock:
65+
# Double-check after acquiring lock (avoid race condition)
66+
if (
67+
self.access_token
68+
and self._access_token_expires
69+
and self._access_token_expires > datetime.now(tz=UTC)
70+
):
71+
return
72+
6273
async with self.sys_websession.post(
6374
f"{self.sys_homeassistant.api_url}/auth/token",
6475
timeout=aiohttp.ClientTimeout(total=30),
@@ -92,7 +103,36 @@ async def make_request(
92103
params: MultiMapping[str] | None = None,
93104
headers: dict[str, str] | None = None,
94105
) -> AsyncIterator[aiohttp.ClientResponse]:
95-
"""Async context manager to make a request with right auth."""
106+
"""Async context manager to make authenticated requests to Home Assistant API.
107+
108+
This context manager handles authentication token management automatically,
109+
including token refresh on 401 responses. It yields the HTTP response
110+
for the caller to handle.
111+
112+
Error Handling:
113+
- HTTP error status codes (4xx, 5xx) are preserved in the response
114+
- Authentication is handled transparently with one retry on 401
115+
- Network/connection failures raise HomeAssistantAPIError
116+
- No logging is performed - callers should handle logging as needed
117+
118+
Args:
119+
method: HTTP method (get, post, etc.)
120+
path: API path relative to Home Assistant base URL
121+
json: JSON data to send in request body
122+
content_type: Override content-type header
123+
data: Raw data to send in request body
124+
timeout: Request timeout in seconds
125+
params: URL query parameters
126+
headers: Additional HTTP headers
127+
128+
Yields:
129+
aiohttp.ClientResponse: The HTTP response object
130+
131+
Raises:
132+
HomeAssistantAPIError: When request cannot be completed due to
133+
network errors, timeouts, or connection failures
134+
135+
"""
96136
url = f"{self.sys_homeassistant.api_url}/{path}"
97137
headers = headers or {}
98138

@@ -101,10 +141,9 @@ async def make_request(
101141
headers[hdrs.CONTENT_TYPE] = content_type
102142

103143
for _ in (1, 2):
104-
await self.ensure_access_token()
105-
headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
106-
107144
try:
145+
await self.ensure_access_token()
146+
headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
108147
async with getattr(self.sys_websession, method)(
109148
url,
110149
data=data,
@@ -120,23 +159,19 @@ async def make_request(
120159
continue
121160
yield resp
122161
return
123-
except TimeoutError:
124-
_LOGGER.error("Timeout on call %s.", url)
125-
break
162+
except TimeoutError as err:
163+
_LOGGER.debug("Timeout on call %s.", url)
164+
raise HomeAssistantAPIError(str(err)) from err
126165
except aiohttp.ClientError as err:
127-
_LOGGER.error("Error on call %s: %s", url, err)
128-
break
129-
130-
raise HomeAssistantAPIError()
166+
_LOGGER.debug("Error on call %s: %s", url, err)
167+
raise HomeAssistantAPIError(str(err)) from err
131168

132169
async def _get_json(self, path: str) -> dict[str, Any]:
133170
"""Return Home Assistant get API."""
134171
async with self.make_request("get", path) as resp:
135172
if resp.status in (200, 201):
136173
return await resp.json()
137-
else:
138-
_LOGGER.debug("Home Assistant API return: %d", resp.status)
139-
raise HomeAssistantAPIError()
174+
raise HomeAssistantAPIError(f"Home Assistant Core API return {resp.status}")
140175

141176
async def get_config(self) -> dict[str, Any]:
142177
"""Return Home Assistant config."""
@@ -155,15 +190,8 @@ async def get_api_state(self) -> APIState | None:
155190
):
156191
return None
157192

158-
# Check if port is up
159-
if not await check_port(
160-
self.sys_homeassistant.ip_address,
161-
self.sys_homeassistant.api_port,
162-
):
163-
return None
164-
165193
# Check if API is up
166-
with suppress(HomeAssistantAPIError):
194+
try:
167195
# get_core_state is available since 2023.8.0 and preferred
168196
# since it is significantly faster than get_config because
169197
# it does not require serializing the entire config
@@ -181,6 +209,8 @@ async def get_api_state(self) -> APIState | None:
181209
migrating = recorder_state.get("migration_in_progress", False)
182210
live_migration = recorder_state.get("migration_is_live", False)
183211
return APIState(state, migrating and not live_migration)
212+
except HomeAssistantAPIError as err:
213+
_LOGGER.debug("Can't connect to Home Assistant API: %s", err)
184214

185215
return None
186216

supervisor/homeassistant/websocket.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from contextlib import suppress
67
import logging
78
from typing import Any, TypeVar, cast
89

@@ -202,7 +203,8 @@ async def _get_ws_client(self) -> WSClient:
202203
if self._client is not None and self._client.connected:
203204
return self._client
204205

205-
await self.sys_homeassistant.api.ensure_access_token()
206+
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
207+
await self.sys_homeassistant.api.ensure_access_token()
206208
client = await WSClient.connect_with_auth(
207209
self.sys_websession,
208210
self.sys_loop,

supervisor/ingress.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
IngressSessionDataDict,
1616
)
1717
from .coresys import CoreSys, CoreSysAttributes
18+
from .exceptions import HomeAssistantAPIError
1819
from .utils import check_port
1920
from .utils.common import FileConfiguration
2021
from .utils.dt import utc_from_timestamp, utcnow
@@ -191,12 +192,17 @@ async def update_hass_panel(self, addon: Addon):
191192

192193
# Update UI
193194
method = "post" if addon.ingress_panel else "delete"
194-
async with self.sys_homeassistant.api.make_request(
195-
method, f"api/hassio_push/panel/{addon.slug}"
196-
) as resp:
197-
if resp.status in (200, 201):
198-
_LOGGER.info("Update Ingress as panel for %s", addon.slug)
199-
else:
200-
_LOGGER.warning(
201-
"Fails Ingress panel for %s with %i", addon.slug, resp.status
202-
)
195+
try:
196+
async with self.sys_homeassistant.api.make_request(
197+
method, f"api/hassio_push/panel/{addon.slug}"
198+
) as resp:
199+
if resp.status in (200, 201):
200+
_LOGGER.info("Update Ingress as panel for %s", addon.slug)
201+
else:
202+
_LOGGER.warning(
203+
"Failed to update the Ingress panel for %s with %i",
204+
addon.slug,
205+
resp.status,
206+
)
207+
except HomeAssistantAPIError as err:
208+
_LOGGER.error("Panel update request failed for %s: %s", addon.slug, err)

supervisor/resolution/notify.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,5 @@ async def issue_notifications(self):
5252
_LOGGER.debug("Successfully created persistent_notification")
5353
else:
5454
_LOGGER.error("Can't create persistant notification")
55-
except HomeAssistantAPIError:
56-
_LOGGER.error("Can't create persistant notification")
55+
except HomeAssistantAPIError as err:
56+
_LOGGER.error("Can't create persistant notification: %s", err)

0 commit comments

Comments
 (0)