mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Add retry logic to Teslemetry coordinators (#160756)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
ddfa6f83c3
commit
ad11c72488
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user