Add exception translations to ring integration (#136468)

* Add exception translations to ring integration

* Do not include exception details in exception translations

* Don't check last_update_success for auth errors and update tests

* Do not log errors twice

* Update post review
This commit is contained in:
Steven B. 2025-02-04 15:14:48 +00:00 committed by GitHub
parent 5629b995ce
commit 2f5816c5b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 134 additions and 60 deletions

View File

@ -31,6 +31,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import RingConfigEntry from . import RingConfigEntry
from .const import DOMAIN
from .coordinator import RingDataCoordinator from .coordinator import RingDataCoordinator
from .entity import RingDeviceT, RingEntity, exception_wrap from .entity import RingDeviceT, RingEntity, exception_wrap
@ -218,8 +219,13 @@ class RingCam(RingEntity[RingDoorBell], Camera):
) -> None: ) -> None:
"""Handle a WebRTC candidate.""" """Handle a WebRTC candidate."""
if candidate.sdp_m_line_index is None: if candidate.sdp_m_line_index is None:
msg = "The sdp_m_line_index is required for ring webrtc streaming" raise HomeAssistantError(
raise HomeAssistantError(msg) translation_domain=DOMAIN,
translation_key="sdp_m_line_index_required",
translation_placeholders={
"device": self._device.name,
},
)
await self._device.on_webrtc_candidate( await self._device.on_webrtc_candidate(
session_id, candidate.candidate, candidate.sdp_m_line_index session_id, candidate.candidate, candidate.sdp_m_line_index
) )

View File

@ -27,7 +27,7 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed, UpdateFailed,
) )
from .const import SCAN_INTERVAL from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -45,26 +45,6 @@ class RingData:
type RingConfigEntry = ConfigEntry[RingData] type RingConfigEntry = ConfigEntry[RingData]
async def _call_api[*_Ts, _R](
hass: HomeAssistant,
target: Callable[[*_Ts], Coroutine[Any, Any, _R]],
*args: *_Ts,
msg_suffix: str = "",
) -> _R:
try:
return await target(*args)
except AuthenticationError as err:
# Raising ConfigEntryAuthFailed will cancel future updates
# and start a config flow with SOURCE_REAUTH (async_step_reauth)
raise ConfigEntryAuthFailed from err
except RingTimeout as err:
raise UpdateFailed(
f"Timeout communicating with API{msg_suffix}: {err}"
) from err
except RingError as err:
raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err
class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
"""Base class for device coordinators.""" """Base class for device coordinators."""
@ -87,12 +67,37 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
self.ring_api: Ring = ring_api self.ring_api: Ring = ring_api
self.first_call: bool = True self.first_call: bool = True
async def _call_api[*_Ts, _R](
self,
target: Callable[[*_Ts], Coroutine[Any, Any, _R]],
*args: *_Ts,
) -> _R:
try:
return await target(*args)
except AuthenticationError as err:
# Raising ConfigEntryAuthFailed will cancel future updates
# and start a config flow with SOURCE_REAUTH (async_step_reauth)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication",
) from err
except RingTimeout as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
) from err
except RingError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
) from err
async def _async_update_data(self) -> RingDevices: async def _async_update_data(self) -> RingDevices:
"""Fetch data from API endpoint.""" """Fetch data from API endpoint."""
update_method: str = ( update_method: str = (
"async_update_data" if self.first_call else "async_update_devices" "async_update_data" if self.first_call else "async_update_devices"
) )
await _call_api(self.hass, getattr(self.ring_api, update_method)) await self._call_api(getattr(self.ring_api, update_method))
self.first_call = False self.first_call = False
devices: RingDevices = self.ring_api.devices() devices: RingDevices = self.ring_api.devices()
subscribed_device_ids = set(self.async_contexts()) subscribed_device_ids = set(self.async_contexts())
@ -104,18 +109,14 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
async with TaskGroup() as tg: async with TaskGroup() as tg:
if device.has_capability("history"): if device.has_capability("history"):
tg.create_task( tg.create_task(
_call_api( self._call_api(
self.hass,
lambda device: device.async_history(limit=10), lambda device: device.async_history(limit=10),
device, device,
msg_suffix=f" for device {device.name}", # device_id is the mac
) )
) )
tg.create_task( tg.create_task(
_call_api( self._call_api(
self.hass,
device.async_update_health_data, device.async_update_health_data,
msg_suffix=f" for device {device.name}",
) )
) )
except ExceptionGroup as eg: except ExceptionGroup as eg:

View File

@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Any, Concatenate, Generic, TypeVar, cast from typing import Any, Concatenate, Generic, TypeVar, cast
from ring_doorbell import ( from ring_doorbell import (
@ -36,6 +37,8 @@ _RingCoordinatorT = TypeVar(
bound=(RingDataCoordinator | RingListenCoordinator), bound=(RingDataCoordinator | RingListenCoordinator),
) )
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True) @dataclass(slots=True)
class DeprecatedInfo: class DeprecatedInfo:
@ -62,14 +65,22 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R](
return await async_func(self, *args, **kwargs) return await async_func(self, *args, **kwargs)
except AuthenticationError as err: except AuthenticationError as err:
self.coordinator.config_entry.async_start_reauth(self.hass) self.coordinator.config_entry.async_start_reauth(self.hass)
raise HomeAssistantError(err) from err raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_authentication",
) from err
except RingTimeout as err: except RingTimeout as err:
raise HomeAssistantError( raise HomeAssistantError(
f"Timeout communicating with API {async_func}: {err}" translation_domain=DOMAIN,
translation_key="api_timeout",
) from err ) from err
except RingError as err: except RingError as err:
_LOGGER.debug(
"Error calling %s in platform %s: ", async_func.__name__, self.platform
)
raise HomeAssistantError( raise HomeAssistantError(
f"Error communicating with API{async_func}: {err}" translation_domain=DOMAIN,
translation_key="api_error",
) from err ) from err
return _wrap return _wrap

View File

@ -141,6 +141,20 @@
} }
} }
}, },
"exceptions": {
"api_authentication": {
"message": "Authentication error communicating with Ring API"
},
"api_timeout": {
"message": "Timeout communicating with Ring API"
},
"api_error": {
"message": "Error communicating with Ring API"
},
"sdp_m_line_index_required": {
"message": "Error negotiating stream for {device}"
}
},
"issues": { "issues": {
"deprecated_entity": { "deprecated_entity": {
"title": "Detected deprecated {platform} entity usage", "title": "Detected deprecated {platform} entity usage",

View File

@ -436,9 +436,9 @@ async def test_camera_webrtc(
assert response assert response
assert response.get("success") is False assert response.get("success") is False
assert response["error"]["code"] == "home_assistant_error" assert response["error"]["code"] == "home_assistant_error"
msg = "The sdp_m_line_index is required for ring webrtc streaming" error_msg = f"Error negotiating stream for {front_camera_mock.name}"
assert msg in response["error"].get("message") assert error_msg in response["error"].get("message")
assert msg in caplog.text assert error_msg in caplog.text
front_camera_mock.on_webrtc_candidate.assert_called_once() front_camera_mock.on_webrtc_candidate.assert_called_once()
# Answer message # Answer message

View File

@ -16,7 +16,7 @@ from homeassistant.components.ring.const import (
CONF_LISTEN_CREDENTIALS, CONF_LISTEN_CREDENTIALS,
SCAN_INTERVAL, SCAN_INTERVAL,
) )
from homeassistant.components.ring.coordinator import RingEventListener from homeassistant.components.ring.coordinator import RingConfigEntry, RingEventListener
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -80,12 +80,12 @@ async def test_auth_failed_on_setup(
("error_type", "log_msg"), ("error_type", "log_msg"),
[ [
( (
RingTimeout, RingTimeout("Some internal error info"),
"Timeout communicating with API: ", "Timeout communicating with Ring API",
), ),
( (
RingError, RingError("Some internal error info"),
"Error communicating with API: ", "Error communicating with Ring API",
), ),
], ],
ids=["timeout-error", "other-error"], ids=["timeout-error", "other-error"],
@ -95,6 +95,7 @@ async def test_error_on_setup(
mock_ring_client, mock_ring_client,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
error_type, error_type,
log_msg, log_msg,
) -> None: ) -> None:
@ -166,11 +167,11 @@ async def test_auth_failure_on_device_update(
[ [
( (
RingTimeout, RingTimeout,
"Error fetching devices data: Timeout communicating with API: ", "Error fetching devices data: Timeout communicating with Ring API",
), ),
( (
RingError, RingError,
"Error fetching devices data: Error communicating with API: ", "Error fetching devices data: Error communicating with Ring API",
), ),
], ],
ids=["timeout-error", "other-error"], ids=["timeout-error", "other-error"],
@ -178,7 +179,7 @@ async def test_auth_failure_on_device_update(
async def test_error_on_global_update( async def test_error_on_global_update(
hass: HomeAssistant, hass: HomeAssistant,
mock_ring_client, mock_ring_client,
mock_config_entry: MockConfigEntry, mock_config_entry: RingConfigEntry,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
error_type, error_type,
@ -189,15 +190,35 @@ async def test_error_on_global_update(
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
mock_ring_client.async_update_devices.side_effect = error_type coordinator = mock_config_entry.runtime_data.devices_coordinator
assert coordinator
freezer.tick(SCAN_INTERVAL) with patch.object(
coordinator, "_async_update_data", wraps=coordinator._async_update_data
) as refresh_spy:
error = error_type("Some internal error info 1")
mock_ring_client.async_update_devices.side_effect = error
freezer.tick(SCAN_INTERVAL * 2)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
refresh_spy.assert_called()
assert coordinator.last_exception.__cause__ == error
assert log_msg in caplog.text assert log_msg in caplog.text
assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) # Check log is not being spammed.
refresh_spy.reset_mock()
error2 = error_type("Some internal error info 2")
caplog.clear()
mock_ring_client.async_update_devices.side_effect = error2
freezer.tick(SCAN_INTERVAL * 2)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
refresh_spy.assert_called()
assert coordinator.last_exception.__cause__ == error2
assert log_msg not in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -205,11 +226,11 @@ async def test_error_on_global_update(
[ [
( (
RingTimeout, RingTimeout,
"Error fetching devices data: Timeout communicating with API for device Front: ", "Error fetching devices data: Timeout communicating with Ring API",
), ),
( (
RingError, RingError,
"Error fetching devices data: Error communicating with API for device Front: ", "Error fetching devices data: Error communicating with Ring API",
), ),
], ],
ids=["timeout-error", "other-error"], ids=["timeout-error", "other-error"],
@ -218,7 +239,7 @@ async def test_error_on_device_update(
hass: HomeAssistant, hass: HomeAssistant,
mock_ring_client, mock_ring_client,
mock_ring_devices, mock_ring_devices,
mock_config_entry: MockConfigEntry, mock_config_entry: RingConfigEntry,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
error_type, error_type,
@ -229,15 +250,36 @@ async def test_error_on_device_update(
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
front_door_doorbell = mock_ring_devices.get_device(765432) coordinator = mock_config_entry.runtime_data.devices_coordinator
front_door_doorbell.async_history.side_effect = error_type assert coordinator
freezer.tick(SCAN_INTERVAL) with patch.object(
coordinator, "_async_update_data", wraps=coordinator._async_update_data
) as refresh_spy:
error = error_type("Some internal error info 1")
front_door_doorbell = mock_ring_devices.get_device(765432)
front_door_doorbell.async_history.side_effect = error
freezer.tick(SCAN_INTERVAL * 2)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
refresh_spy.assert_called()
assert coordinator.last_exception.__cause__ == error
assert log_msg in caplog.text assert log_msg in caplog.text
assert hass.config_entries.async_get_entry(mock_config_entry.entry_id)
# Check log is not being spammed.
error2 = error_type("Some internal error info 2")
front_door_doorbell.async_history.side_effect = error2
refresh_spy.reset_mock()
caplog.clear()
freezer.tick(SCAN_INTERVAL * 2)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
refresh_spy.assert_called()
assert coordinator.last_exception.__cause__ == error2
assert log_msg not in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(