Update fitbit error handling (#101304)

* Update fitbit error handling

* Update exceptions to inherit HomeAssistantError and add reason code

* Revert config flow exception mapping hack
This commit is contained in:
Allen Porter 2023-10-05 22:38:15 -07:00 committed by GitHub
parent 1d31def982
commit c7d533d427
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 203 additions and 49 deletions

View file

@ -1,8 +1,5 @@
"""The fitbit component.""" """The fitbit component."""
from http import HTTPStatus
import aiohttp
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
@ -12,6 +9,7 @@ from homeassistant.helpers import config_entry_oauth2_flow
from . import api from . import api
from .const import DOMAIN from .const import DOMAIN
from .exceptions import FitbitApiException, FitbitAuthException
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -31,11 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
try: try:
await fitbit_api.async_get_access_token() await fitbit_api.async_get_access_token()
except aiohttp.ClientResponseError as err: except FitbitAuthException as err:
if err.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from err
raise ConfigEntryAuthFailed from err except FitbitApiException as err:
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
hass.data[DOMAIN][entry.entry_id] = fitbit_api hass.data[DOMAIN][entry.entry_id] = fitbit_api

View file

@ -1,10 +1,12 @@
"""API for fitbit bound to Home Assistant OAuth.""" """API for fitbit bound to Home Assistant OAuth."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable
import logging import logging
from typing import Any, cast from typing import Any, TypeVar, cast
from fitbit import Fitbit from fitbit import Fitbit
from fitbit.exceptions import HTTPException, HTTPUnauthorized
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -12,6 +14,7 @@ from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import FitbitUnitSystem from .const import FitbitUnitSystem
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice, FitbitProfile from .model import FitbitDevice, FitbitProfile
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -20,6 +23,9 @@ CONF_REFRESH_TOKEN = "refresh_token"
CONF_EXPIRES_AT = "expires_at" CONF_EXPIRES_AT = "expires_at"
_T = TypeVar("_T")
class FitbitApi(ABC): class FitbitApi(ABC):
"""Fitbit client library wrapper base class. """Fitbit client library wrapper base class.
@ -58,9 +64,7 @@ class FitbitApi(ABC):
"""Return the user profile from the API.""" """Return the user profile from the API."""
if self._profile is None: if self._profile is None:
client = await self._async_get_client() client = await self._async_get_client()
response: dict[str, Any] = await self._hass.async_add_executor_job( response: dict[str, Any] = await self._run(client.user_profile_get)
client.user_profile_get
)
_LOGGER.debug("user_profile_get=%s", response) _LOGGER.debug("user_profile_get=%s", response)
profile = response["user"] profile = response["user"]
self._profile = FitbitProfile( self._profile = FitbitProfile(
@ -95,9 +99,7 @@ class FitbitApi(ABC):
async def async_get_devices(self) -> list[FitbitDevice]: async def async_get_devices(self) -> list[FitbitDevice]:
"""Return available devices.""" """Return available devices."""
client = await self._async_get_client() client = await self._async_get_client()
devices: list[dict[str, str]] = await self._hass.async_add_executor_job( devices: list[dict[str, str]] = await self._run(client.get_devices)
client.get_devices
)
_LOGGER.debug("get_devices=%s", devices) _LOGGER.debug("get_devices=%s", devices)
return [ return [
FitbitDevice( FitbitDevice(
@ -120,12 +122,23 @@ class FitbitApi(ABC):
def _time_series() -> dict[str, Any]: def _time_series() -> dict[str, Any]:
return cast(dict[str, Any], client.time_series(resource_type, period="7d")) return cast(dict[str, Any], client.time_series(resource_type, period="7d"))
response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) response: dict[str, Any] = await self._run(_time_series)
_LOGGER.debug("time_series(%s)=%s", resource_type, response) _LOGGER.debug("time_series(%s)=%s", resource_type, response)
key = resource_type.replace("/", "-") key = resource_type.replace("/", "-")
dated_results: list[dict[str, Any]] = response[key] dated_results: list[dict[str, Any]] = response[key]
return dated_results[-1] return dated_results[-1]
async def _run(self, func: Callable[[], _T]) -> _T:
"""Run client command."""
try:
return await self._hass.async_add_executor_job(func)
except HTTPUnauthorized as err:
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
raise FitbitAuthException from err
except HTTPException as err:
_LOGGER.debug("Error from fitbit API: %s", err)
raise FitbitApiException from err
class OAuthFitbitApi(FitbitApi): class OAuthFitbitApi(FitbitApi):
"""Provide fitbit authentication tied to an OAuth2 based config entry.""" """Provide fitbit authentication tied to an OAuth2 based config entry."""

View file

@ -5,9 +5,12 @@ details on Fitbit authorization.
""" """
import base64 import base64
from http import HTTPStatus
import logging import logging
from typing import Any, cast from typing import Any, cast
import aiohttp
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
AuthImplementation, AuthImplementation,
AuthorizationServer, AuthorizationServer,
@ -18,6 +21,7 @@ from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from .exceptions import FitbitApiException, FitbitAuthException
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -31,26 +35,37 @@ class FitbitOAuth2Implementation(AuthImplementation):
async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict: async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict:
"""Resolve the authorization code to tokens.""" """Resolve the authorization code to tokens."""
session = async_get_clientsession(self.hass) return await self._post(
data = { {
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": external_data["code"], "code": external_data["code"],
"redirect_uri": external_data["state"]["redirect_uri"], "redirect_uri": external_data["state"]["redirect_uri"],
} }
resp = await session.post(self.token_url, data=data, headers=self._headers) )
resp.raise_for_status()
return cast(dict, await resp.json())
async def _token_request(self, data: dict) -> dict: async def _token_request(self, data: dict) -> dict:
"""Make a token request.""" """Make a token request."""
return await self._post(
{
**data,
CONF_CLIENT_ID: self.client_id,
CONF_CLIENT_SECRET: self.client_secret,
}
)
async def _post(self, data: dict[str, Any]) -> dict[str, Any]:
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
body = { try:
**data, resp = await session.post(self.token_url, data=data, headers=self._headers)
CONF_CLIENT_ID: self.client_id, resp.raise_for_status()
CONF_CLIENT_SECRET: self.client_secret, except aiohttp.ClientResponseError as err:
} error_body = await resp.text()
resp = await session.post(self.token_url, data=body, headers=self._headers) _LOGGER.debug("Client response error body: %s", error_body)
resp.raise_for_status() if err.status == HTTPStatus.UNAUTHORIZED:
raise FitbitAuthException from err
raise FitbitApiException from err
except aiohttp.ClientError as err:
raise FitbitApiException from err
return cast(dict, await resp.json()) return cast(dict, await resp.json())
@property @property

View file

@ -4,8 +4,6 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from fitbit.exceptions import HTTPException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_TOKEN
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
@ -13,6 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow
from . import api from . import api
from .const import DOMAIN, OAUTH_SCOPES from .const import DOMAIN, OAUTH_SCOPES
from .exceptions import FitbitApiException, FitbitAuthException
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -60,7 +59,10 @@ class OAuth2FlowHandler(
client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN]) client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN])
try: try:
profile = await client.async_get_user_profile() profile = await client.async_get_user_profile()
except HTTPException as err: except FitbitAuthException as err:
_LOGGER.error("Failed to authenticate with Fitbit API: %s", err)
return self.async_abort(reason="invalid_access_token")
except FitbitApiException as err:
_LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err) _LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err)
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")

View file

@ -0,0 +1,14 @@
"""Exceptions for fitbit API calls.
These exceptions exist to provide common exceptions for the async and sync client libraries.
"""
from homeassistant.exceptions import HomeAssistantError
class FitbitApiException(HomeAssistantError):
"""Error talking to the fitbit API."""
class FitbitAuthException(FitbitApiException):
"""Authentication related error talking to the fitbit API."""

View file

@ -59,6 +59,7 @@ from .const import (
FITBIT_DEFAULT_RESOURCES, FITBIT_DEFAULT_RESOURCES,
FitbitUnitSystem, FitbitUnitSystem,
) )
from .exceptions import FitbitApiException
from .model import FitbitDevice from .model import FitbitDevice
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
@ -707,12 +708,22 @@ class FitbitSensor(SensorEntity):
resource_type = self.entity_description.key resource_type = self.entity_description.key
if resource_type == "devices/battery" and self.device is not None: if resource_type == "devices/battery" and self.device is not None:
device_id = self.device.id device_id = self.device.id
registered_devs: list[FitbitDevice] = await self.api.async_get_devices() try:
self.device = next( registered_devs: list[FitbitDevice] = await self.api.async_get_devices()
device for device in registered_devs if device.id == device_id except FitbitApiException:
) self._attr_available = False
self._attr_native_value = self.device.battery else:
self._attr_available = True
self.device = next(
device for device in registered_devs if device.id == device_id
)
self._attr_native_value = self.device.battery
return
else: try:
result = await self.api.async_get_latest_time_series(resource_type) result = await self.api.async_get_latest_time_series(resource_type)
except FitbitApiException:
self._attr_available = False
else:
self._attr_available = True
self._attr_native_value = self.entity_description.value_fn(result) self._attr_native_value = self.entity_description.value_fn(result)

View file

@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -88,6 +89,20 @@ async def test_full_flow(
} }
@pytest.mark.parametrize(
("http_status", "json", "error_reason"),
[
(HTTPStatus.INTERNAL_SERVER_ERROR, None, "cannot_connect"),
(HTTPStatus.FORBIDDEN, None, "cannot_connect"),
(
HTTPStatus.UNAUTHORIZED,
{
"errors": [{"errorType": "invalid_grant"}],
},
"invalid_access_token",
),
],
)
async def test_api_failure( async def test_api_failure(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
@ -95,6 +110,9 @@ async def test_api_failure(
current_request_with_host: None, current_request_with_host: None,
requests_mock: Mocker, requests_mock: Mocker,
setup_credentials: None, setup_credentials: None,
http_status: HTTPStatus,
json: Any,
error_reason: str,
) -> None: ) -> None:
"""Test a failure to fetch the profile during the setup flow.""" """Test a failure to fetch the profile during the setup flow."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -126,12 +144,15 @@ async def test_api_failure(
) )
requests_mock.register_uri( requests_mock.register_uri(
"GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR "GET",
PROFILE_API_URL,
status_code=http_status,
json=json,
) )
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") == FlowResultType.ABORT assert result.get("type") == FlowResultType.ABORT
assert result.get("reason") == "cannot_connect" assert result.get("reason") == error_reason
async def test_config_entry_already_exists( async def test_config_entry_already_exists(

View file

@ -2,17 +2,25 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from http import HTTPStatus
from typing import Any from typing import Any
import pytest import pytest
from requests_mock.mocker import Mocker
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fitbit.const import DOMAIN from homeassistant.components.fitbit.const import DOMAIN
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity
from .conftest import PROFILE_USER_ID, timeseries_response from .conftest import (
DEVICES_API_URL,
PROFILE_USER_ID,
TIMESERIES_API_URL_FORMAT,
timeseries_response,
)
DEVICE_RESPONSE_CHARGE_2 = { DEVICE_RESPONSE_CHARGE_2 = {
"battery": "Medium", "battery": "Medium",
@ -359,7 +367,6 @@ async def test_activity_scope_config_entry(
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
register_timeseries: Callable[[str, dict[str, Any]], None], register_timeseries: Callable[[str, dict[str, Any]], None],
entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test activity sensors are enabled.""" """Test activity sensors are enabled."""
@ -404,7 +411,6 @@ async def test_heartrate_scope_config_entry(
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
register_timeseries: Callable[[str, dict[str, Any]], None], register_timeseries: Callable[[str, dict[str, Any]], None],
entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test heartrate sensors are enabled.""" """Test heartrate sensors are enabled."""
@ -429,7 +435,6 @@ async def test_sleep_scope_config_entry(
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
register_timeseries: Callable[[str, dict[str, Any]], None], register_timeseries: Callable[[str, dict[str, Any]], None],
entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test sleep sensors are enabled.""" """Test sleep sensors are enabled."""
@ -471,7 +476,6 @@ async def test_weight_scope_config_entry(
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
register_timeseries: Callable[[str, dict[str, Any]], None], register_timeseries: Callable[[str, dict[str, Any]], None],
entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test sleep sensors are enabled.""" """Test sleep sensors are enabled."""
@ -493,7 +497,6 @@ async def test_settings_scope_config_entry(
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
register_timeseries: Callable[[str, dict[str, Any]], None], register_timeseries: Callable[[str, dict[str, Any]], None],
entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test heartrate sensors are enabled.""" """Test heartrate sensors are enabled."""
@ -510,3 +513,82 @@ async def test_settings_scope_config_entry(
assert [s.entity_id for s in states] == [ assert [s.entity_id for s in states] == [
"sensor.charge_2_battery", "sensor.charge_2_battery",
] ]
@pytest.mark.parametrize(
("scopes"),
[(["heartrate"])],
)
async def test_sensor_update_failed(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
requests_mock: Mocker,
) -> None:
"""Test a failed sensor update when talking to the API."""
requests_mock.register_uri(
"GET",
TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
assert await integration_setup()
state = hass.states.get("sensor.resting_heart_rate")
assert state
assert state.state == "unavailable"
@pytest.mark.parametrize(
("scopes", "mock_devices"),
[(["settings"], None)],
)
async def test_device_battery_level_update_failed(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
requests_mock: Mocker,
) -> None:
"""Test API failure for a battery level sensor for devices."""
requests_mock.register_uri(
"GET",
DEVICES_API_URL,
[
{
"status_code": HTTPStatus.OK,
"json": [DEVICE_RESPONSE_CHARGE_2],
},
# A second spurious update request on startup
{
"status_code": HTTPStatus.OK,
"json": [DEVICE_RESPONSE_CHARGE_2],
},
# Fail when requesting an update
{
"status_code": HTTPStatus.INTERNAL_SERVER_ERROR,
"json": {
"errors": [
{
"errorType": "request",
"message": "An error occurred",
}
]
},
},
],
)
assert await integration_setup()
state = hass.states.get("sensor.charge_2_battery")
assert state
assert state.state == "Medium"
# Request an update for the entity which will fail
await async_update_entity(hass, "sensor.charge_2_battery")
state = hass.states.get("sensor.charge_2_battery")
assert state
assert state.state == "unavailable"