Improve error handling and add exception translations for NextDNS integration (#141005)

* Add exception translations

* Coverage

* Add missing auth_error

* Coverage

* Use async_start_reauth

* Fix test

* Remove method placeholder
This commit is contained in:
Maciej Bieniek 2025-03-20 19:33:45 +01:00 committed by GitHub
parent a338205b73
commit 53f1dd8adf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 174 additions and 13 deletions

View File

@ -36,6 +36,7 @@ from .const import (
ATTR_SETTINGS, ATTR_SETTINGS,
ATTR_STATUS, ATTR_STATUS,
CONF_PROFILE_ID, CONF_PROFILE_ID,
DOMAIN,
UPDATE_INTERVAL_ANALYTICS, UPDATE_INTERVAL_ANALYTICS,
UPDATE_INTERVAL_CONNECTION, UPDATE_INTERVAL_CONNECTION,
UPDATE_INTERVAL_SETTINGS, UPDATE_INTERVAL_SETTINGS,
@ -88,9 +89,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b
try: try:
nextdns = await NextDns.create(websession, api_key) nextdns = await NextDns.create(websession, api_key)
except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err: except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={
"entry": entry.title,
"error": repr(err),
},
) from err
except InvalidApiKeyError as err: except InvalidApiKeyError as err:
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders={"entry": entry.title},
) from err
tasks = [] tasks = []
coordinators = {} coordinators = {}

View File

@ -2,15 +2,19 @@
from __future__ import annotations from __future__ import annotations
from nextdns import AnalyticsStatus from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError
from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry from . import NextDnsConfigEntry
from .const import DOMAIN
from .coordinator import NextDnsUpdateCoordinator from .coordinator import NextDnsUpdateCoordinator
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -53,4 +57,21 @@ class NextDnsButton(
async def async_press(self) -> None: async def async_press(self) -> None:
"""Trigger cleaning logs.""" """Trigger cleaning logs."""
await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) try:
await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id)
except (
ApiError,
ClientConnectorError,
TimeoutError,
ClientError,
) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="method_error",
translation_placeholders={
"entity": self.entity_id,
"error": repr(err),
},
) from err
except InvalidApiKeyError:
self.coordinator.config_entry.async_start_reauth(self.hass)

View File

@ -79,9 +79,20 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]):
ClientConnectorError, ClientConnectorError,
RetryError, RetryError,
) as err: ) as err:
raise UpdateFailed(err) from err raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"entry": self.config_entry.title,
"error": repr(err),
},
) from err
except InvalidApiKeyError as err: except InvalidApiKeyError as err:
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders={"entry": self.config_entry.title},
) from err
async def _async_update_data_internal(self) -> CoordinatorDataT: async def _async_update_data_internal(self) -> CoordinatorDataT:
"""Update data via library.""" """Update data via library."""

View File

@ -359,5 +359,19 @@
"name": "Force YouTube restricted mode" "name": "Force YouTube restricted mode"
} }
} }
},
"exceptions": {
"auth_error": {
"message": "Authentication failed for {entry}, please update your API key"
},
"cannot_connect": {
"message": "An error occurred while connecting to the NextDNS API for {entry}: {error}"
},
"method_error": {
"message": "An error occurred while calling the NextDNS API method for {entity}: {error}"
},
"update_error": {
"message": "An error occurred while retrieving data from the NextDNS API for {entry}: {error}"
}
} }
} }

View File

@ -8,7 +8,7 @@ from typing import Any
from aiohttp import ClientError from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from nextdns import ApiError, Settings from nextdns import ApiError, InvalidApiKeyError, Settings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry from . import NextDnsConfigEntry
from .const import DOMAIN
from .coordinator import NextDnsUpdateCoordinator from .coordinator import NextDnsUpdateCoordinator
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -582,9 +583,16 @@ class NextDnsSwitch(
ClientError, ClientError,
) as err: ) as err:
raise HomeAssistantError( raise HomeAssistantError(
"NextDNS API returned an error calling set_setting for" translation_domain=DOMAIN,
f" {self.entity_id}: {err}" translation_key="method_error",
translation_placeholders={
"entity": self.entity_id,
"error": repr(err),
},
) from err ) from err
except InvalidApiKeyError:
self.coordinator.config_entry.async_start_reauth(self.hass)
return
if result: if result:
self._attr_is_on = new_state self._attr_is_on = new_state

View File

@ -1,12 +1,19 @@
"""Test button of NextDNS integration.""" """Test button of NextDNS integration."""
from unittest.mock import patch from unittest.mock import Mock, patch
from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError
from nextdns import ApiError, InvalidApiKeyError
import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.nextdns.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -36,7 +43,7 @@ async def test_button_press(hass: HomeAssistant) -> None:
): ):
await hass.services.async_call( await hass.services.async_call(
BUTTON_DOMAIN, BUTTON_DOMAIN,
"press", SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"},
blocking=True, blocking=True,
) )
@ -47,3 +54,60 @@ async def test_button_press(hass: HomeAssistant) -> None:
state = hass.states.get("button.fake_profile_clear_logs") state = hass.states.get("button.fake_profile_clear_logs")
assert state assert state
assert state.state == now.isoformat() assert state.state == now.isoformat()
@pytest.mark.parametrize(
"exc",
[
ApiError(Mock()),
TimeoutError,
ClientConnectorError(Mock(), Mock()),
ClientError,
],
)
async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None:
"""Tests that the press action throws HomeAssistantError."""
await init_integration(hass)
with (
patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc),
pytest.raises(
HomeAssistantError,
match="An error occurred while calling the NextDNS API method for button.fake_profile_clear_logs",
),
):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.fake_profile_clear_logs"},
blocking=True,
)
async def test_button_auth_error(hass: HomeAssistant) -> None:
"""Tests that the press action starts re-auth flow."""
entry = await init_integration(hass)
with patch(
"homeassistant.components.nextdns.NextDns.clear_logs",
side_effect=InvalidApiKeyError,
):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.fake_profile_clear_logs"},
blocking=True,
)
assert entry.state is ConfigEntryState.LOADED
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow.get("step_id") == "reauth_confirm"
assert flow.get("handler") == DOMAIN
assert "context" in flow
assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == entry.entry_id

View File

@ -5,12 +5,14 @@ from unittest.mock import Mock, patch
from aiohttp import ClientError from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from nextdns import ApiError from nextdns import ApiError, InvalidApiKeyError
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from tenacity import RetryError from tenacity import RetryError
from homeassistant.components.nextdns.const import DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
@ -158,3 +160,32 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None:
{ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, {ATTR_ENTITY_ID: "switch.fake_profile_block_page"},
blocking=True, blocking=True,
) )
async def test_switch_auth_error(hass: HomeAssistant) -> None:
"""Tests that the turn on/off action starts re-auth flow."""
entry = await init_integration(hass)
with patch(
"homeassistant.components.nextdns.NextDns.set_setting",
side_effect=InvalidApiKeyError,
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.fake_profile_block_page"},
blocking=True,
)
assert entry.state is ConfigEntryState.LOADED
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow.get("step_id") == "reauth_confirm"
assert flow.get("handler") == DOMAIN
assert "context" in flow
assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == entry.entry_id