diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 836761a9..1c5fcfda 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -102,8 +102,8 @@ async def refresh_listener(invocation: Details) -> None: retry_on_connection_failure = backoff.on_exception( backoff.expo, (TimeoutError, ClientConnectorError), - max_tries=5, - max_time=120, + max_tries=3, + max_time=30, jitter=backoff.full_jitter, logger=_LOGGER, ) @@ -476,7 +476,6 @@ async def register_event_listener(self) -> str: @retry_on_concurrent_requests @retry_on_auth_error @retry_on_listener_error - @retry_on_connection_failure async def fetch_events(self) -> list[Event]: """Fetch new events from a registered event listener. Fetched events are removed. @@ -1021,6 +1020,7 @@ async def deactivate_developer_mode(self, gateway_id: str) -> None: """ await self._delete(f"setup/gateways/{gateway_id}/developerMode") + @retry_on_connection_failure async def _get(self, path: str) -> Any: """Make a GET request to the OverKiz API.""" await self._refresh_token_if_expired() @@ -1032,6 +1032,7 @@ async def _get(self, path: str) -> Any: ) as response: return await self._parse_response(response) + @retry_on_connection_failure async def _post( self, path: str, @@ -1050,6 +1051,7 @@ async def _post( ) as response: return await self._parse_response(response) + @retry_on_connection_failure async def _put(self, path: str, payload: dict[str, Any] | None = None) -> Any: """Make a PUT request to the OverKiz API.""" await self._refresh_token_if_expired() @@ -1062,6 +1064,7 @@ async def _put(self, path: str, payload: dict[str, Any] | None = None) -> Any: ) as response: return await self._parse_response(response) + @retry_on_connection_failure async def _delete(self, path: str) -> None: """Make a DELETE request to the OverKiz API.""" await self._refresh_token_if_expired() diff --git a/tests/test_client.py b/tests/test_client.py index 4956b713..d6556b58 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -68,6 +68,48 @@ async def test_get_devices_basic(self, client: OverkizClient): devices = await client.get_devices() assert len(devices) == 23 + @pytest.mark.asyncio + async def test_backoff_retries_command_on_connection_failure( + self, client: OverkizClient + ) -> None: + """Ensure the command path retries a transient connection failure.""" + resp = MockResponse(json.dumps({"execId": "exec-1"})) + + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "post", + side_effect=[TimeoutError("timed out"), resp], + ) as post_mock, + ): + exec_id = await client.execute_action_group( + actions=[Action(device_url="io://1234-5678-9012/12345678")], + ) + + assert exec_id == "exec-1" + assert post_mock.call_count == 2 + assert sleep_mock.await_count == 1 + + @pytest.mark.asyncio + async def test_backoff_gives_up_after_max_tries_on_connection_failure( + self, client: OverkizClient + ) -> None: + """Ensure a persistent connection failure is re-raised after 3 attempts.""" + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "get", + side_effect=TimeoutError("timed out"), + ) as get_mock, + pytest.raises(TimeoutError), + ): + await client.get_api_version() + + assert get_mock.call_count == 3 + assert sleep_mock.await_count == 2 + @pytest.mark.parametrize( ("fixture_name", "event_length"), [