From 9e3eb20a04dacc5f518e93d26973afb8edfc8a07 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Wed, 5 Nov 2025 06:23:20 -0800 Subject: [PATCH] Fix account link no internet on startup (#154579) Co-authored-by: Martin Hjelmare --- .../components/cloud/account_link.py | 7 +- .../helpers/config_entry_oauth2_flow.py | 16 ++- .../integration/__init__.py | 17 ++- tests/components/cloud/test_account_link.py | 4 +- .../helpers/test_config_entry_oauth2_flow.py | 113 ++++++++++++++++++ 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 3c3d944d479..2978a400bfd 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -71,8 +71,11 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: services = await account_link.async_fetch_available_services( hass.data[DATA_CLOUD] ) - except (aiohttp.ClientError, TimeoutError): - return [] + except (aiohttp.ClientError, TimeoutError) as err: + raise config_entry_oauth2_flow.ImplementationUnavailableError( + "Cannot provide OAuth2 implementation for cloud services. " + "Failed to fetch from account link server." + ) from err hass.data[DATA_SERVICES] = services diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 0f8bdfd7793..56b97eefa11 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -29,6 +29,7 @@ from yarl import URL from homeassistant import config_entries from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_application_credentials from homeassistant.util.hass_dict import HassKey @@ -61,6 +62,10 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +class ImplementationUnavailableError(HomeAssistantError): + """Raised when an underlying implementation is unavailable.""" + + @callback def async_get_redirect_uri(hass: HomeAssistant) -> str: """Return the redirect uri.""" @@ -563,9 +568,16 @@ async def async_get_implementations( return registered registered = dict(registered) + exceptions = [] for get_impl in list(hass.data[DATA_PROVIDERS].values()): - for impl in await get_impl(hass, domain): - registered[impl.domain] = impl + try: + for impl in await get_impl(hass, domain): + registered[impl.domain] = impl + except ImplementationUnavailableError as err: + exceptions.append(err) + + if not registered and exceptions: + raise ImplementationUnavailableError(*exceptions) return registered diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index 8eaf8b0e25a..38f628cd18f 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -5,7 +5,11 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import api @@ -21,11 +25,16 @@ type New_NameConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] # # TODO Update entry annotation async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + "OAuth2 implementation temporarily unavailable, will retry" + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) diff --git a/tests/components/cloud/test_account_link.py b/tests/components/cloud/test_account_link.py index cd81a7cf691..aa75fcb5dae 100644 --- a/tests/components/cloud/test_account_link.py +++ b/tests/components/cloud/test_account_link.py @@ -177,9 +177,9 @@ async def test_get_services_error(hass: HomeAssistant) -> None: "hass_nabucasa.account_link.async_fetch_available_services", side_effect=TimeoutError, ), + pytest.raises(config_entry_oauth2_flow.ImplementationUnavailableError), ): - assert await account_link._get_services(hass) == [] - assert account_link.DATA_SERVICES not in hass.data + await account_link._get_services(hass) @pytest.mark.usefixtures("current_request_with_host") diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index f250f97cfd4..3ef37b8c90a 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -1137,3 +1137,116 @@ def test_compute_code_challenge_invalid_code_verifier(code_verifier: str) -> Non config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.compute_code_challenge( code_verifier ) + + +async def test_async_get_config_entry_implementation_with_failing_provider_and_succeeding_provider( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, +) -> None: + """Test async_get_config_entry_implementation when one provider fails but another succeeds.""" + + async def failing_cloud_provider( + _hass: HomeAssistant, _domain: str + ) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: + """Provider that raises an exception.""" + raise config_entry_oauth2_flow.ImplementationUnavailableError + + async def successful_local_provider( + _hass: HomeAssistant, _domain: str + ) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: + """Provider that returns implementations.""" + return [local_impl] + + config_entry_oauth2_flow.async_add_implementation_provider( + hass, "cloud", failing_cloud_provider + ) + config_entry_oauth2_flow.async_add_implementation_provider( + hass, "application_credentials", successful_local_provider + ) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": local_impl.domain, + }, + ) + + # This should succeed and return the local implementation + # even though the failing cloud provider raised an exception. + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + assert implementation is local_impl + + +async def test_async_get_config_entry_implementation_with_failing_provider( + hass: HomeAssistant, +) -> None: + """Test async_get_config_entry_implementation when one provider fails and the other is empty.""" + + async def failing_cloud_provider( + _hass: HomeAssistant, _domain: str + ) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: + """Provider that raises an exception.""" + raise config_entry_oauth2_flow.ImplementationUnavailableError + + async def empty_local_provider( + _hass: HomeAssistant, _domain: str + ) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: + """Provider that returns implementations.""" + return [] + + config_entry_oauth2_flow.async_add_implementation_provider( + hass, "cloud", failing_cloud_provider + ) + config_entry_oauth2_flow.async_add_implementation_provider( + hass, "application_credentials", empty_local_provider + ) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + }, + ) + + # This should fail since the local provider returned an empty list + # and the cloud provider raised an exception. + with pytest.raises(config_entry_oauth2_flow.ImplementationUnavailableError): + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + + +async def test_async_get_config_entry_implementation_missing_provider( + hass: HomeAssistant, +) -> None: + """Test async_get_config_entry_implementation when both providers are empty.""" + + async def empty_provider( + _hass: HomeAssistant, _domain: str + ) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: + """Provider that returns implementations.""" + return [] + + config_entry_oauth2_flow.async_add_implementation_provider( + hass, "cloud", empty_provider + ) + config_entry_oauth2_flow.async_add_implementation_provider( + hass, "application_credentials", empty_provider + ) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + }, + ) + + # This should fail since both providers are empty. + with pytest.raises(ValueError, match="Implementation not available"): + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + )