diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 0dffd2b73f6b..fa80e55ddb6f 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, override from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( GatewayTimeout, + InsufficientCredits, InvalidResponse, InvalidToken, LoginRequired, @@ -49,6 +50,10 @@ ENERGY_INFO_INTERVAL = timedelta(seconds=30) ENERGY_HISTORY_INTERVAL = timedelta(seconds=60) METADATA_INTERVAL = timedelta(hours=1) +# Insufficient credits will not resolve themselves quickly, so back off polling +# instead of hammering the API at the coordinator's normal interval. +INSUFFICIENT_CREDITS_RETRY_AFTER = timedelta(hours=1).total_seconds() + ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, VehicleDataEndpoint.CLIMATE_STATE, @@ -139,6 +144,12 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e + except InsufficientCredits as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_insufficient_credits", + retry_after=INSUFFICIENT_CREDITS_RETRY_AFTER, + ) from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 2a82475cb20b..e2ea6d0312a6 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1182,6 +1182,9 @@ "update_failed": { "message": "Error fetching data from Teslemetry API: {message}" }, + "update_failed_insufficient_credits": { + "message": "Teslemetry account has insufficient command credits, pausing updates until credits are added" + }, "update_failed_invalid_data": { "message": "Received invalid data from API" }, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 06a9cab91928..3598dc5da597 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -10,6 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( Forbidden, + InsufficientCredits, InvalidResponse, InvalidToken, LoginRequired, @@ -25,6 +26,7 @@ from homeassistant.components.teslemetry.coordinator import ( ENERGY_HISTORY_INTERVAL, ENERGY_INFO_INTERVAL, ENERGY_LIVE_INTERVAL, + INSUFFICIENT_CREDITS_RETRY_AFTER, METADATA_INTERVAL, VEHICLE_INTERVAL, ) @@ -39,6 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import UpdateFailed from . import setup_platform from .const import ( @@ -64,6 +67,11 @@ ERRORS = [ (TeslaFleetError, ConfigEntryState.SETUP_RETRY), ] +VEHICLE_ERRORS = [ + *ERRORS, + (InsufficientCredits, ConfigEntryState.SETUP_RETRY), +] + async def test_load_unload(hass: HomeAssistant) -> None: """Test load and unload.""" @@ -103,7 +111,7 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +@pytest.mark.parametrize(("side_effect", "state"), VEHICLE_ERRORS) async def test_vehicle_refresh_error( hass: HomeAssistant, mock_vehicle_data: AsyncMock, @@ -998,3 +1006,37 @@ async def test_dynamic_device_discovery_no_reload_without_changes( # Verify reload was NOT triggered since no subscription changes mock_reload.assert_not_called() + + +async def test_insufficient_credits_backs_off_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Running out of command credits should back off, not hammer the API every poll.""" + call_count = 0 + + def vehicle_data_side_effect(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return deepcopy(VEHICLE_DATA) + raise InsufficientCredits + + mock_vehicle_data.side_effect = vehicle_data_side_effect + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert call_count == 1 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert call_count == 2 + assert entry.state is ConfigEntryState.LOADED + + coordinator = entry.runtime_data.vehicles[0].coordinator + assert isinstance(coordinator.last_exception, UpdateFailed) + assert coordinator.last_exception.retry_after == INSUFFICIENT_CREDITS_RETRY_AFTER