Fix Teslemetry insufficient-credits polling storm (#175913)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Brett Adams
2026-07-10 19:56:25 +00:00
committed by Franck Nijhof
co-authored by Copilot Autofix powered by AI
parent 6cde36014e
commit c2a1784340
3 changed files with 57 additions and 1 deletions
@@ -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,
@@ -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"
},
+43 -1
View File
@@ -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