mirror of
https://github.com/home-assistant/core.git
synced 2026-07-11 02:51:25 +02:00
Fix Teslemetry dead-token retry storm (#175671)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user