Handle Zonneplan rate limit and API errors (#183594)

This commit is contained in:
Erwin Douna
2026-09-29 18:29:53 +02:00
committed by GitHub
parent 25970a4f60
commit 55c6e86de8
5 changed files with 81 additions and 1 deletions
@@ -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"
@@ -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
@@ -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"
}
@@ -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"),
],
)
+43 -1
View File
@@ -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,