diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 4f8a8a06c295..f9815ff9f46d 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -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, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 3598dc5da597..2e37d0c1d9c6 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -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()