Fix Teslemetry dead-token retry storm (#175671)

This commit is contained in:
Brett Adams
2026-07-11 06:03:51 +10:00
committed by GitHub
parent e0a6f7cd8c
commit b34412d10f
2 changed files with 150 additions and 10 deletions
@@ -5,7 +5,7 @@ from collections.abc import Callable
from functools import partial
from typing import Any, Final, cast
from aiohttp import ClientError, ClientResponseError
from aiohttp import ClientError
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
@@ -21,10 +21,15 @@ from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -90,18 +95,32 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str:
oauth_session.valid_token,
oauth_session.token.get("expires_at"),
)
setup_in_progress = (
oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
)
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as err:
if err.status == 401:
except OAuth2TokenRequestReauthError as err:
if setup_in_progress:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="not_ready_connection_error",
) from err
# Not in setup: let the coordinator's own OAuth2TokenRequestError
# handling stop polling and (re)start reauth without tearing
# down the already-loaded entry.
oauth_session.config_entry.async_start_reauth(oauth_session.hass)
raise
except OAuth2TokenRequestError as err:
# Recoverable (e.g. 429/5xx). During setup this backs off via the
# normal ConfigEntryNotReady retry; once loaded, let it propagate so
# the coordinator treats it as a transient failed update instead.
if setup_in_progress:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="not_ready_connection_error",
) from err
raise
except (KeyError, TypeError) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
+122 -1
View File
@@ -19,6 +19,7 @@ from tesla_fleet_api.exceptions import (
TeslaFleetError,
)
from homeassistant.components.teslemetry import _get_access_token
from homeassistant.components.teslemetry.const import CLIENT_ID, DOMAIN
# Coordinator constants
@@ -31,6 +32,7 @@ from homeassistant.components.teslemetry.coordinator import (
VEHICLE_INTERVAL,
)
from homeassistant.components.teslemetry.models import TeslemetryData
from homeassistant.components.teslemetry.oauth import TeslemetryImplementation
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
STATE_OFF,
@@ -40,10 +42,17 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import UpdateFailed
from . import setup_platform
from . import mock_config_entry, setup_platform
from .const import (
CONFIG_V1,
ENERGY_HISTORY,
@@ -1040,3 +1049,115 @@ async def test_insufficient_credits_backs_off_polling(
coordinator = entry.runtime_data.vehicles[0].coordinator
assert isinstance(coordinator.last_exception, UpdateFailed)
assert coordinator.last_exception.retry_after == INSUFFICIENT_CREDITS_RETRY_AFTER
def _oauth_session(hass: HomeAssistant, entry: MockConfigEntry) -> OAuth2Session:
"""Build an OAuth2Session for directly exercising _get_access_token."""
return OAuth2Session(hass, entry, TeslemetryImplementation(hass, DOMAIN, CLIENT_ID))
async def test_get_access_token_dead_token_during_setup_triggers_auth_failed(
hass: HomeAssistant,
) -> None:
"""A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed.
OAuth servers commonly report a dead refresh token with a non-401 status
(e.g. 400 invalid_grant). Only recognizing status 401 let this fall
through to ConfigEntryNotReady, which retries setup indefinitely without
ever prompting the user to reauthenticate.
"""
mock_entry = mock_config_entry()
mock_entry.add_to_hass(hass)
mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
session = _oauth_session(hass, mock_entry)
with (
patch.object(
OAuth2Session,
"async_ensure_token_valid",
side_effect=OAuth2TokenRequestReauthError(
request_info=MagicMock(), status=400, domain=DOMAIN
),
),
pytest.raises(ConfigEntryAuthFailed),
):
await _get_access_token(session)
async def test_get_access_token_rate_limited_during_setup_is_not_fatal(
hass: HomeAssistant,
) -> None:
"""A 429 from the token endpoint during setup should back off, not be fatal."""
mock_entry = mock_config_entry()
mock_entry.add_to_hass(hass)
mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
session = _oauth_session(hass, mock_entry)
with (
patch.object(
OAuth2Session,
"async_ensure_token_valid",
side_effect=OAuth2TokenRequestTransientError(
request_info=MagicMock(), status=429, domain=DOMAIN
),
),
pytest.raises(ConfigEntryNotReady),
):
await _get_access_token(session)
async def test_get_access_token_dead_token_after_setup_starts_reauth(
hass: HomeAssistant,
) -> None:
"""Test a token dying after setup (re)starts reauth without tearing down.
The coordinator handles the rest once the exception is re-raised.
"""
mock_entry = mock_config_entry()
mock_entry.add_to_hass(hass)
mock_entry.mock_state(hass, ConfigEntryState.LOADED)
session = _oauth_session(hass, mock_entry)
with (
patch.object(
OAuth2Session,
"async_ensure_token_valid",
side_effect=OAuth2TokenRequestReauthError(
request_info=MagicMock(), status=400, domain=DOMAIN
),
),
pytest.raises(OAuth2TokenRequestReauthError),
):
await _get_access_token(session)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert any(
flow["handler"] == DOMAIN and flow["context"].get("source") == "reauth"
for flow in flows
)
async def test_get_access_token_rate_limited_after_setup_is_not_fatal(
hass: HomeAssistant,
) -> None:
"""A transient token-refresh error after setup must not force reauth."""
mock_entry = mock_config_entry()
mock_entry.add_to_hass(hass)
mock_entry.mock_state(hass, ConfigEntryState.LOADED)
session = _oauth_session(hass, mock_entry)
with (
patch.object(
OAuth2Session,
"async_ensure_token_valid",
side_effect=OAuth2TokenRequestTransientError(
request_info=MagicMock(), status=429, domain=DOMAIN
),
),
pytest.raises(OAuth2TokenRequestTransientError),
):
await _get_access_token(session)
await hass.async_block_till_done()
assert not hass.config_entries.flow.async_progress()