diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index a73424bdd288..577db30641a6 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, Any from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( + GatewayTimeout, + InvalidResponse, InvalidToken, + RateLimited, + ServiceUnavailable, SubscriptionRequired, TeslaFleetError, ) @@ -23,6 +27,22 @@ if TYPE_CHECKING: from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten +RETRY_EXCEPTIONS = ( + InvalidResponse, + RateLimited, + ServiceUnavailable, + GatewayTimeout, +) + + +def _get_retry_after(e: TeslaFleetError) -> float: + """Calculate wait time from exception.""" + if isinstance(e.data, dict): + if after := e.data.get("after"): + return float(after) + return 10.0 + + VEHICLE_INTERVAL = timedelta(seconds=60) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_LIVE_INTERVAL = timedelta(seconds=30) @@ -69,14 +89,14 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Teslemetry API.""" - try: data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: + raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - return flatten(data) @@ -111,19 +131,18 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" - try: data = (await self.api.live_status())["response"] except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: + raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - # Convert Wall Connectors from array to dict data["wall_connectors"] = { wc["din"]: wc for wc in (data.get("wall_connectors") or []) } - return data @@ -152,14 +171,14 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" - try: data = (await self.api.site_info())["response"] except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: + raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - return flatten(data) @@ -187,11 +206,12 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" - try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: + raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 29bb72081009..949a3c298296 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,5 +1,6 @@ """Test the Teslemetry init.""" +from copy import deepcopy import time from unittest.mock import AsyncMock, MagicMock, patch @@ -8,13 +9,21 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( + InvalidResponse, InvalidToken, + RateLimited, SubscriptionRequired, TeslaFleetError, ) from homeassistant.components.teslemetry.const import CLIENT_ID, DOMAIN -from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL + +# Coordinator constants +from homeassistant.components.teslemetry.coordinator import ( + ENERGY_HISTORY_INTERVAL, + ENERGY_LIVE_INTERVAL, + VEHICLE_INTERVAL, +) from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -28,9 +37,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import CONFIG_V1, PRODUCTS_MODERN, UNIQUE_ID, VEHICLE_DATA_ALT +from .const import ( + CONFIG_V1, + ENERGY_HISTORY, + LIVE_STATUS, + PRODUCTS_MODERN, + UNIQUE_ID, + VEHICLE_DATA_ALT, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), @@ -425,3 +441,175 @@ async def test_migrate_from_future_version_fails(hass: HomeAssistant) -> None: assert entry is not None assert entry.state is ConfigEntryState.MIGRATION_ERROR assert entry.version == 3 # Version should remain unchanged + + +RETRY_EXCEPTIONS = [ + (RateLimited(data={"after": 5}), 5.0), + (InvalidResponse(), 10.0), +] + + +@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS) +async def test_site_info_retry_exceptions( + hass: HomeAssistant, + mock_site_info: AsyncMock, + exception: TeslaFleetError, + expected_retry_after: float, +) -> None: + """Test UpdateFailed with retry_after for site info coordinator.""" + mock_site_info.side_effect = exception + entry = await setup_platform(hass) + # Retry exceptions during first refresh cause setup retry + assert entry.state is ConfigEntryState.SETUP_RETRY + # API should only be called once (no manual retries) + assert mock_site_info.call_count == 1 + + +@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS) +async def test_vehicle_data_retry_exceptions( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, + exception: TeslaFleetError, + expected_retry_after: float, +) -> None: + """Test UpdateFailed with retry_after for vehicle data coordinator.""" + mock_vehicle_data.side_effect = exception + entry = await setup_platform(hass) + # Retry exceptions during first refresh cause setup retry + assert entry.state is ConfigEntryState.SETUP_RETRY + # API should only be called once (no manual retries) + assert mock_vehicle_data.call_count == 1 + + +@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS) +async def test_live_status_coordinator_retry_exceptions( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_live_status: AsyncMock, + exception: TeslaFleetError, + expected_retry_after: float, +) -> None: + """Test live status coordinator raises UpdateFailed with retry_after.""" + call_count = 0 + + def live_status_side_effect(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return deepcopy(LIVE_STATUS) # Initial call succeeds + if call_count == 2: + raise exception # Second call raises exception + return deepcopy(LIVE_STATUS) # Subsequent calls succeed + + mock_live_status.side_effect = live_status_side_effect + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert call_count == 1 + + # Trigger coordinator refresh - this will raise the exception + freezer.tick(ENERGY_LIVE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # API was called exactly once for this refresh (no manual retry loop) + assert call_count == 2 + # Entry stays loaded - UpdateFailed with retry_after doesn't break the entry + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS) +async def test_energy_history_coordinator_retry_exceptions( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_energy_history: AsyncMock, + exception: TeslaFleetError, + expected_retry_after: float, +) -> None: + """Test energy history coordinator raises UpdateFailed with retry_after.""" + call_count = 0 + + def energy_history_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise exception # First call raises exception + return ENERGY_HISTORY # Subsequent calls succeed + + mock_energy_history.side_effect = energy_history_side_effect + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + # Energy history doesn't have first_refresh during setup + assert call_count == 0 + + # Trigger first coordinator refresh - this will raise the exception + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # API was called exactly once (no manual retry loop) + assert call_count == 1 + # Entry stays loaded - UpdateFailed with retry_after doesn't break the entry + assert entry.state is ConfigEntryState.LOADED + + +async def test_live_status_auth_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test live status coordinator handles auth errors.""" + call_count = 0 + + def live_status_side_effect(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return deepcopy(LIVE_STATUS) + raise InvalidToken + + with patch( + "tesla_fleet_api.tesla.energysite.EnergySite.live_status", + side_effect=live_status_side_effect, + ): + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + + # Trigger a coordinator refresh by advancing time + freezer.tick(ENERGY_LIVE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Auth error triggers reauth flow + assert entry.state is ConfigEntryState.LOADED + + +async def test_live_status_generic_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test live status coordinator handles generic TeslaFleetError.""" + call_count = 0 + + def live_status_side_effect(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return deepcopy(LIVE_STATUS) + raise TeslaFleetError + + with patch( + "tesla_fleet_api.tesla.energysite.EnergySite.live_status", + side_effect=live_status_side_effect, + ): + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + + # Trigger a coordinator refresh by advancing time + freezer.tick(ENERGY_LIVE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Entry stays loaded but coordinator will have failed + assert entry.state is ConfigEntryState.LOADED