From 902649589052c8f247801afa092642d6dd127365 Mon Sep 17 00:00:00 2001 From: Hmmbob Date: Mon, 12 Oct 2026 14:44:51 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=8E=20Clarify=20PoolLab=20API=20ti?= =?UTF-8?q?meout=20and=20empty=20error=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/poollab/__init__.py | 14 +++++- tests/test_coordinator_errors.py | 62 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/test_coordinator_errors.py diff --git a/custom_components/poollab/__init__.py b/custom_components/poollab/__init__.py index 8f8d488..c873cf2 100644 --- a/custom_components/poollab/__init__.py +++ b/custom_components/poollab/__init__.py @@ -19,6 +19,7 @@ from .poollab import API_ENDPOINT, CloudAccount, PoolLabApi PLATFORMS = ["sensor"] +REQUEST_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) @@ -41,14 +42,23 @@ def __init__(self, hass: HomeAssistant, api: PoolLabApi) -> None: async def _async_update_poollab(self): """Fetch data from API endpoint.""" try: - async with asyncio.timeout(10): + async with asyncio.timeout(REQUEST_TIMEOUT): return await self.api.request() except TransportQueryError as err: # Raising ConfigEntryAuthFailed will cancel future updates # and start a config flow with SOURCE_REAUTH (async_step_reauth) raise ConfigEntryAuthFailed from err + except TimeoutError as err: + raise UpdateFailed( + f"PoolLab cloud request timed out after {REQUEST_TIMEOUT} seconds; " + "no new measurements were fetched. Home Assistant will retry automatically." + ) from err except Exception as err: - raise UpdateFailed(f"Unknown error communicating with API: {err}") from err + detail = str(err).strip() or "no error message provided" + raise UpdateFailed( + f"PoolLab API request failed ({type(err).__name__}): {detail}. " + "Home Assistant will retry automatically." + ) from err def add_entity_ref(self, entity) -> None: """Add an entity reference to the coordinator.""" diff --git a/tests/test_coordinator_errors.py b/tests/test_coordinator_errors.py new file mode 100644 index 0000000..5a6ddf2 --- /dev/null +++ b/tests/test_coordinator_errors.py @@ -0,0 +1,62 @@ +"""Regression tests for PoolLab API error messages.""" + +import asyncio +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.poollab import PoolLabCoordinator +from custom_components.poollab.poollab import PoolLabApi + + +@pytest.mark.parametrize( + ("error", "expected_message"), + [ + ( + TimeoutError(), + "PoolLab cloud request timed out after 10 seconds; " + "no new measurements were fetched. Home Assistant will retry automatically.", + ), + ( + RuntimeError(), + "PoolLab API request failed (RuntimeError): no error message provided. " + "Home Assistant will retry automatically.", + ), + ( + ConnectionError("Connection lost"), + "PoolLab API request failed (ConnectionError): Connection lost. " + "Home Assistant will retry automatically.", + ), + ], +) +async def test_api_failure_has_useful_message(hass, error, expected_message): + """Report the failure type even when the exception message is empty.""" + api = Mock(spec=PoolLabApi) + api.request = AsyncMock(side_effect=error) + coordinator = PoolLabCoordinator(hass, api) + + with pytest.raises(UpdateFailed) as exc_info: + await coordinator._async_update_poollab() + + assert str(exc_info.value) == expected_message + assert exc_info.value.__cause__ is error + api.request.assert_awaited_once() + + +async def test_request_deadline_has_timeout_message(hass, monkeypatch): + """An expired request deadline must use the timeout-specific message.""" + monkeypatch.setattr("custom_components.poollab.REQUEST_TIMEOUT", 0) + + async def pending_request(): + await asyncio.sleep(0) + + api = Mock(spec=PoolLabApi) + api.request = AsyncMock(side_effect=pending_request) + coordinator = PoolLabCoordinator(hass, api) + + with pytest.raises(UpdateFailed, match="cloud request timed out after 0 seconds"): + await coordinator._async_update_poollab() + + assert api.request.await_count == 1 From 3643009830dce016f5a16374499ab4ac9fe9d1a1 Mon Sep 17 00:00:00 2001 From: Hmmbob Date: Mon, 12 Oct 2026 16:38:40 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Keep=20API=20error=20message?= =?UTF-8?q?s=20independent=20of=20timeout=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/poollab/__init__.py | 5 ++--- tests/test_coordinator_errors.py | 9 ++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/custom_components/poollab/__init__.py b/custom_components/poollab/__init__.py index c873cf2..bc41bf3 100644 --- a/custom_components/poollab/__init__.py +++ b/custom_components/poollab/__init__.py @@ -19,7 +19,6 @@ from .poollab import API_ENDPOINT, CloudAccount, PoolLabApi PLATFORMS = ["sensor"] -REQUEST_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) @@ -42,7 +41,7 @@ def __init__(self, hass: HomeAssistant, api: PoolLabApi) -> None: async def _async_update_poollab(self): """Fetch data from API endpoint.""" try: - async with asyncio.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(10): return await self.api.request() except TransportQueryError as err: # Raising ConfigEntryAuthFailed will cancel future updates @@ -50,7 +49,7 @@ async def _async_update_poollab(self): raise ConfigEntryAuthFailed from err except TimeoutError as err: raise UpdateFailed( - f"PoolLab cloud request timed out after {REQUEST_TIMEOUT} seconds; " + "PoolLab cloud request timed out; " "no new measurements were fetched. Home Assistant will retry automatically." ) from err except Exception as err: diff --git a/tests/test_coordinator_errors.py b/tests/test_coordinator_errors.py index 5a6ddf2..b25061e 100644 --- a/tests/test_coordinator_errors.py +++ b/tests/test_coordinator_errors.py @@ -16,7 +16,7 @@ [ ( TimeoutError(), - "PoolLab cloud request timed out after 10 seconds; " + "PoolLab cloud request timed out; " "no new measurements were fetched. Home Assistant will retry automatically.", ), ( @@ -47,7 +47,10 @@ async def test_api_failure_has_useful_message(hass, error, expected_message): async def test_request_deadline_has_timeout_message(hass, monkeypatch): """An expired request deadline must use the timeout-specific message.""" - monkeypatch.setattr("custom_components.poollab.REQUEST_TIMEOUT", 0) + timeout = asyncio.timeout + monkeypatch.setattr( + "custom_components.poollab.asyncio.timeout", lambda _: timeout(0) + ) async def pending_request(): await asyncio.sleep(0) @@ -56,7 +59,7 @@ async def pending_request(): api.request = AsyncMock(side_effect=pending_request) coordinator = PoolLabCoordinator(hass, api) - with pytest.raises(UpdateFailed, match="cloud request timed out after 0 seconds"): + with pytest.raises(UpdateFailed, match="cloud request timed out;"): await coordinator._async_update_poollab() assert api.request.await_count == 1