Add retry logic to Teslemetry coordinators (#160756)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Brett Adams
2026-01-14 01:36:43 +01:00
committed by GitHub
co-authored by Claude Opus 4.5
parent ddfa6f83c3
commit ad11c72488
2 changed files with 219 additions and 11 deletions
@@ -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
+191 -3
View File
@@ -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