Wrap internal ZHA exceptions in HomeAssistantErrors (#97033)

This commit is contained in:
puddly 2023-07-24 03:12:21 -04:00 committed by GitHub
parent 797a9c1ead
commit 84220e92ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 73 additions and 9 deletions

View file

@ -2,10 +2,11 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from enum import Enum
from functools import partialmethod
import functools
import logging
from typing import TYPE_CHECKING, Any, TypedDict
from typing import TYPE_CHECKING, Any, ParamSpec, TypedDict
import zigpy.exceptions
import zigpy.util
@ -19,6 +20,7 @@ from zigpy.zcl.foundation import (
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from ..const import (
@ -45,8 +47,34 @@ if TYPE_CHECKING:
from ..endpoint import Endpoint
_LOGGER = logging.getLogger(__name__)
RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3)
retry_request = zigpy.util.retryable_request(tries=3)
_P = ParamSpec("_P")
_FuncType = Callable[_P, Awaitable[Any]]
_ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]]
def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]:
"""Send a request with retries and wrap expected zigpy exceptions."""
@functools.wraps(func)
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
try:
return await RETRYABLE_REQUEST_DECORATOR(func)(*args, **kwargs)
except asyncio.TimeoutError as exc:
raise HomeAssistantError(
"Failed to send request: device did not respond"
) from exc
except zigpy.exceptions.ZigbeeException as exc:
message = "Failed to send request"
if str(exc):
message = f"{message}: {exc}"
raise HomeAssistantError(message) from exc
return wrapper
class AttrReportConfig(TypedDict, total=True):
@ -471,7 +499,7 @@ class ClusterHandler(LogMixin):
rest = rest[ZHA_CLUSTER_HANDLER_READS_PER_REQ:]
return result
get_attributes = partialmethod(_get_attributes, False)
get_attributes = functools.partialmethod(_get_attributes, False)
def log(self, level, msg, *args, **kwargs):
"""Log a message."""

View file

@ -22,6 +22,7 @@ from homeassistant.components.zha.core.device import ZHADevice
from homeassistant.components.zha.core.endpoint import Endpoint
import homeassistant.components.zha.core.registries as registries
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .common import get_zha_gateway, make_zcl_header
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
@ -831,3 +832,37 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None:
zha_endpoint.add_all_cluster_handlers()
assert "missing_attr" in caplog.text
# parametrize side effects:
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(zigpy.exceptions.ZigbeeException(), "Failed to send request"),
(
zigpy.exceptions.ZigbeeException("Zigbee exception"),
"Failed to send request: Zigbee exception",
),
(asyncio.TimeoutError(), "Failed to send request: device did not respond"),
],
)
async def test_retry_request(
side_effect: Exception | None, expected_error: str | None
) -> None:
"""Test the `retry_request` decorator's handling of zigpy-internal exceptions."""
async def func(arg1: int, arg2: int) -> int:
assert arg1 == 1
assert arg2 == 2
raise side_effect
func = mock.AsyncMock(wraps=func)
decorated_func = cluster_handlers.retry_request(func)
with pytest.raises(HomeAssistantError) as exc:
await decorated_func(1, arg2=2)
assert func.await_count == 3
assert isinstance(exc.value, HomeAssistantError)
assert str(exc.value) == expected_error

View file

@ -26,6 +26,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from .common import (
async_enable_traffic,
@ -236,7 +237,7 @@ async def test_shade(
# close from UI command fails
with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError):
with pytest.raises(asyncio.TimeoutError):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER,
@ -261,7 +262,7 @@ async def test_shade(
assert ATTR_CURRENT_POSITION not in hass.states.get(entity_id).attributes
await send_attributes_report(hass, cluster_level, {0: 0})
with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError):
with pytest.raises(asyncio.TimeoutError):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
@ -285,7 +286,7 @@ async def test_shade(
# set position UI command fails
with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError):
with pytest.raises(asyncio.TimeoutError):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
@ -326,7 +327,7 @@ async def test_shade(
# test cover stop
with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError):
with pytest.raises(asyncio.TimeoutError):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_STOP_COVER,
@ -395,7 +396,7 @@ async def test_keen_vent(
p2 = patch.object(cluster_level, "request", return_value=[4, 0])
with p1, p2:
with pytest.raises(asyncio.TimeoutError):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,