diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 44460ed1928b2a..e8d8abfea4c76b 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -9,6 +9,7 @@ GlancesApiError, GlancesApiNoDataAvailable, ) +import httpx from homeassistant.const import ( CONF_HOST, @@ -19,17 +20,23 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.util.hass_dict import HassKey +from .const import DEFAULT_TIMEOUT from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator +DATA_HTTPX_CLIENT: HassKey[dict[bool, httpx.AsyncClient]] = HassKey( + "glances_httpx_client" +) + PLATFORMS = [Platform.SENSOR] @@ -63,9 +70,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: GlancesConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +@callback +def _async_get_httpx_client(hass: HomeAssistant, verify_ssl: bool) -> httpx.AsyncClient: + """Return a cached Glances httpx client (one per verify_ssl value). + + The shared httpx client cannot be used because it has a 5-second timeout + that is too short for slow Glances hosts. Caching here ensures entry + reloads reuse the same client instead of leaking one on every reload. + """ + clients = hass.data.setdefault(DATA_HTTPX_CLIENT, {}) + if (client := clients.get(verify_ssl)) is None: + client = clients[verify_ssl] = create_async_httpx_client( + hass, verify_ssl=verify_ssl, timeout=DEFAULT_TIMEOUT + ) + return client + + async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: """Return the api from glances_api.""" - httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL]) + httpx_client = _async_get_httpx_client(hass, entry_data[CONF_VERIFY_SSL]) for version in (4, 3): api = Glances( host=entry_data[CONF_HOST], diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 6831ccb9e3b64a..63e0bcb8c0fbb9 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -9,5 +9,6 @@ DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_TIMEOUT = 30 CPU_ICON = f"mdi:cpu-{64 if sys.maxsize > 2**32 else 32}-bit" diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 5df8fe1b2e4fb5..4db7779f3c49df 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -46,7 +46,7 @@ async def _async_update_data(self) -> dict[str, Any]: except exceptions.GlancesApiAuthorizationError as err: raise ConfigEntryAuthFailed from err except exceptions.GlancesApiError as err: - raise UpdateFailed from err + raise UpdateFailed(str(err)) from err # Update computed values uptime: datetime | None = None up_duration: timedelta | None = None diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 16d4d9d371bb13..4906c7e1858c70 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,7 +1,8 @@ """Tests for Glances integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from glances_api.exceptions import ( GlancesApiAuthorizationError, GlancesApiConnectionError, @@ -9,13 +10,18 @@ ) import pytest -from homeassistant.components.glances.const import DOMAIN +from homeassistant.components.glances.const import ( + DEFAULT_SCAN_INTERVAL, + DEFAULT_TIMEOUT, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed from . import MOCK_USER_INPUT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_successful_config_entry(hass: HomeAssistant) -> None: @@ -53,6 +59,58 @@ async def test_setup_error( assert entry.state is entry_state +async def test_update_error_includes_message( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_api: MagicMock, +) -> None: + """Test that the underlying API error message is propagated to UpdateFailed.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError( + "Connection to http://localhost:61209/api/4/all failed" + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + coordinator = entry.runtime_data + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, UpdateFailed) + assert "Connection to http://localhost:61209/api/4/all failed" in str( + coordinator.last_exception + ) + + +async def test_dedicated_httpx_client_uses_timeout_and_is_cached( + hass: HomeAssistant, +) -> None: + """The integration's dedicated httpx client uses DEFAULT_TIMEOUT and is reused on reload.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.glances.create_async_httpx_client" + ) as mock_create: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + assert mock_create.call_count == 1 + kwargs = mock_create.call_args.kwargs + assert kwargs["timeout"] == DEFAULT_TIMEOUT + assert kwargs["verify_ssl"] == MOCK_USER_INPUT["verify_ssl"] + + assert await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert mock_create.call_count == 1 + + async def test_unload_entry(hass: HomeAssistant) -> None: """Test removing Glances.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT)