From 55c6e86de8a78989c33b80d70826edab1b0ebd8d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 29 Sep 2026 18:29:53 +0200 Subject: [PATCH] Handle Zonneplan rate limit and API errors (#183594) --- .../components/zonneplan/config_flow.py | 5 +++ .../components/zonneplan/coordinator.py | 13 ++++++ .../components/zonneplan/strings.json | 7 +++ .../components/zonneplan/test_config_flow.py | 13 ++++++ tests/components/zonneplan/test_init.py | 44 ++++++++++++++++++- 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zonneplan/config_flow.py b/homeassistant/components/zonneplan/config_flow.py index 4cb55d2d7664..01450e078ef1 100644 --- a/homeassistant/components/zonneplan/config_flow.py +++ b/homeassistant/components/zonneplan/config_flow.py @@ -10,6 +10,7 @@ from pyzonneplan import ( Zonneplan, ZonneplanConnectionError, ZonneplanInvalidOtpError, + ZonneplanRateLimitError, ZonneplanTimeoutError, ) @@ -67,6 +68,8 @@ class ZonneplanConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"} except ZonneplanTimeoutError: return {"base": "timeout_connect"} + except ZonneplanRateLimitError: + return {"base": "rate_limited"} except Exception: LOGGER.exception("Unexpected exception") return {"base": "unknown"} @@ -104,6 +107,8 @@ class ZonneplanConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except ZonneplanTimeoutError: errors["base"] = "timeout_connect" + except ZonneplanRateLimitError: + errors["base"] = "rate_limited" except Exception: LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/zonneplan/coordinator.py b/homeassistant/components/zonneplan/coordinator.py index fbc439ab6fdb..16b6699f07b9 100644 --- a/homeassistant/components/zonneplan/coordinator.py +++ b/homeassistant/components/zonneplan/coordinator.py @@ -13,6 +13,8 @@ from pyzonneplan import ( Zonneplan, ZonneplanAuthenticationError, ZonneplanConnectionError, + ZonneplanError, + ZonneplanRateLimitError, ZonneplanTimeoutError, ) from pyzonneplan.const import PriceChart @@ -107,6 +109,17 @@ class ZonneplanCoordinator(DataUpdateCoordinator[ZonneplanData]): translation_domain=DOMAIN, translation_key="cannot_connect", ) from err + except ZonneplanRateLimitError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="rate_limited", + retry_after=err.retry_after, + ) from err + except ZonneplanError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err if TYPE_CHECKING: assert self.zonneplan.token is not None diff --git a/homeassistant/components/zonneplan/strings.json b/homeassistant/components/zonneplan/strings.json index 8553b557d80c..6707796f99ec 100644 --- a/homeassistant/components/zonneplan/strings.json +++ b/homeassistant/components/zonneplan/strings.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "rate_limited": "Too many requests to the Zonneplan API. Wait a minute and try again.", "timeout_connect": "Timeout while communicating with the Zonneplan API", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -111,12 +112,18 @@ } }, "exceptions": { + "api_error": { + "message": "Unexpected error from the Zonneplan API" + }, "cannot_connect": { "message": "Error communicating with the Zonneplan API" }, "invalid_auth": { "message": "An error occurred while trying to authenticate" }, + "rate_limited": { + "message": "The Zonneplan API rate limit was reached" + }, "timeout_connect": { "message": "Timeout while communicating with the Zonneplan API" } diff --git a/tests/components/zonneplan/test_config_flow.py b/tests/components/zonneplan/test_config_flow.py index c9a3716d3f84..e80fc8cc24a4 100644 --- a/tests/components/zonneplan/test_config_flow.py +++ b/tests/components/zonneplan/test_config_flow.py @@ -9,6 +9,7 @@ from pyzonneplan import ( Token, ZonneplanConnectionError, ZonneplanInvalidOtpError, + ZonneplanRateLimitError, ZonneplanTimeoutError, ) @@ -64,6 +65,9 @@ async def test_full_flow(hass: HomeAssistant, mock_zonneplan_client: AsyncMock) [ pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param( + ZonneplanRateLimitError("slow down", retry_after=30), "rate_limited" + ), pytest.param(Exception("unexpected"), "unknown"), ], ) @@ -114,6 +118,9 @@ async def test_step_user_exceptions( [ pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param( + ZonneplanRateLimitError("slow down", retry_after=30), "rate_limited" + ), pytest.param(ZonneplanInvalidOtpError("bad otp"), "invalid_auth"), pytest.param(Exception("unexpected"), "unknown"), ], @@ -225,6 +232,9 @@ async def test_reauth_flow( [ pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param( + ZonneplanRateLimitError("slow down", retry_after=30), "rate_limited" + ), pytest.param(Exception("unexpected"), "unknown"), ], ) @@ -335,6 +345,9 @@ async def test_reconfigure_flow( [ pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param( + ZonneplanRateLimitError("slow down", retry_after=30), "rate_limited" + ), pytest.param(Exception("unexpected"), "unknown"), ], ) diff --git a/tests/components/zonneplan/test_init.py b/tests/components/zonneplan/test_init.py index 6fec8b121afd..9489d63e3528 100644 --- a/tests/components/zonneplan/test_init.py +++ b/tests/components/zonneplan/test_init.py @@ -3,20 +3,24 @@ from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from pyzonneplan import ( Token, ZonneplanAuthenticationError, ZonneplanConnectionError, + ZonneplanRateLimitError, + ZonneplanRequestError, ZonneplanTimeoutError, ) +from homeassistant.components.zonneplan.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -37,6 +41,16 @@ from tests.common import MockConfigEntry ConfigEntryState.SETUP_RETRY, id="connection_error", ), + pytest.param( + ZonneplanRateLimitError("slow down", retry_after=30), + ConfigEntryState.SETUP_RETRY, + id="rate_limit_error", + ), + pytest.param( + ZonneplanRequestError("rejected"), + ConfigEntryState.SETUP_RETRY, + id="request_error", + ), ], ) async def test_setup_entry_update_failed( @@ -56,6 +70,34 @@ async def test_setup_entry_update_failed( assert mock_config_entry.state is expected_state +async def test_rate_limit_postpones_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a rate limit schedules the next update after its Retry-After.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_zonneplan_client.async_get_account.call_count == 1 + + mock_zonneplan_client.async_get_account.side_effect = ZonneplanRateLimitError( + "slow down", retry_after=120 + ) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_zonneplan_client.async_get_account.call_count == 2 + + # The next update follows Retry-After instead of the 15-minute interval. + mock_zonneplan_client.async_get_account.side_effect = None + freezer.tick(timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_zonneplan_client.async_get_account.call_count == 3 + + async def test_setup_entry_persists_rotated_token( hass: HomeAssistant, mock_config_entry: MockConfigEntry,