sandbox: drop a failed entry so entry_setup can be retried (Phase 6)

A failed async_setup left the rebuilt ConfigEntry in the sandbox's
config_entries (only unload popped it), so main's later retry of the same
entry_id was rejected with 'entry already loaded'. On both failure paths
(async_setup raised / returned False) the entry is now popped before
returning ok=False, so a re-sent entry_setup starts clean.

Sandbox-side half of plan 1's SETUP_RETRY decision: main shipped honest
SETUP_ERROR + manual reload and remains the only retry driver. The
sandbox-side ConfigEntryNotReady timer is deliberately NOT enabled (the
sandbox hass is never async_started).
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:24 -04:00
parent 040ca1fe14
commit e6e109c51b
2 changed files with 90 additions and 0 deletions
@@ -103,10 +103,19 @@ class EntryRunner:
_LOGGER.exception(
"sandbox entry_setup raised for %s (%s)", entry.title, entry.domain
)
# Drop the failed entry so a re-sent entry_setup for the same
# entry_id isn't rejected with "entry already loaded". Main is the
# only retry driver (the sandbox hass is never started, so its own
# ConfigEntryNotReady timer never fires) — this just makes the
# re-send start clean.
config_entries._entries.pop(entry.entry_id, None) # noqa: SLF001
return pb.EntrySetupResult(
ok=False, reason=str(err) or err.__class__.__name__
)
if not ok:
# Same cleanup on a plain failed setup (returns False / SETUP_ERROR
# / SETUP_RETRY) so the entry_id is free for main's retry.
config_entries._entries.pop(entry.entry_id, None) # noqa: SLF001
return pb.EntrySetupResult(
ok=False, reason=entry.reason or f"async_setup returned {ok!r}"
)
@@ -173,6 +173,87 @@ async def test_entry_setup_reports_failure_reason(
assert result.HasField("reason")
async def test_failed_entry_setup_is_retryable(
channels: tuple[Channel, Channel], runner: EntryRunner
) -> None:
"""A failed setup frees the entry_id so main can re-send entry_setup.
The first attempt fails; the entry must not linger in the sandbox's
config_entries (else the retry is rejected with "entry already loaded").
The second attempt for the same entry_id then succeeds.
"""
main, sandbox = channels
runner.register(sandbox)
main.start()
sandbox.start()
attempts: list[str] = []
async def _async_setup_entry(hass: Any, entry: ConfigEntry) -> bool:
attempts.append(entry.entry_id)
# Fail the first attempt, succeed the second.
return len(attempts) >= 2
async def _async_unload_entry(_hass: Any, _entry: ConfigEntry) -> bool:
return True
class _DemoFlow(ConfigFlow, domain="demo_retry"):
VERSION = 1
assert "demo_retry" in ha_config_entries.HANDLERS
module = ModuleType("homeassistant.components.demo_retry")
module.DOMAIN = "demo_retry"
module.async_setup_entry = _async_setup_entry # type: ignore[attr-defined]
module.async_unload_entry = _async_unload_entry # type: ignore[attr-defined]
config_flow_module = ModuleType("homeassistant.components.demo_retry.config_flow")
runner.hass.data[ha_loader.DATA_COMPONENTS]["demo_retry"] = module
runner.hass.data[ha_loader.DATA_COMPONENTS]["demo_retry.config_flow"] = (
config_flow_module
)
integration = ha_loader.Integration(
runner.hass,
"homeassistant.components.demo_retry",
None,
{
"domain": "demo_retry",
"name": "Demo Retry",
"config_flow": True,
"documentation": "https://example.com",
"iot_class": "local_polling",
"requirements": [],
"dependencies": [],
"codeowners": [],
},
None,
)
runner.hass.data[ha_loader.DATA_INTEGRATIONS] = runner.hass.data.get(
ha_loader.DATA_INTEGRATIONS, {}
)
runner.hass.data[ha_loader.DATA_INTEGRATIONS]["demo_retry"] = integration
payload = pb.EntrySetup(
entry_id="retry_entry_id",
domain="demo_retry",
title="Demo Retry",
source="user",
version=1,
minor_version=1,
)
# First attempt fails → ok=False and the entry is dropped.
result1 = await main.call("sandbox/entry_setup", payload)
assert result1.ok is False
assert runner.hass.config_entries.async_get_entry("retry_entry_id") is None
# Retry with the same entry_id succeeds — no "entry already loaded".
result2 = await main.call("sandbox/entry_setup", payload)
assert result2.ok is True
assert not result2.HasField("reason")
assert attempts == ["retry_entry_id", "retry_entry_id"]
async def test_call_service_dispatches_through_services(
channels: tuple[Channel, Channel], runner: EntryRunner
) -> None: