Fix produce-box select cancelling config entry setup
async_added_to_hass listed the switchable boxes inline, one fetch per box popup, so on a slower connection the platform setup ran past SLOW_SETUP_MAX_WAIT and the whole config entry was cancelled into setup_error — every entity unavailable despite valid credentials. Move the listing to a background task so setup never blocks on it; the options fill in once it returns. Also bound every portal request to a 30s timeout so one hung request can no longer drag a refresh, or a first setup, past Home Assistant's own limits and fail it outright. Regression covered in tests/test_setup_hygiene.py.
This commit is contained in:
@@ -25,11 +25,18 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from .api import BASE, USER_AGENT, FreshHarvestClient, FreshHarvestError
|
||||
from .api import (
|
||||
BASE,
|
||||
REQUEST_TIMEOUT,
|
||||
USER_AGENT,
|
||||
FreshHarvestClient,
|
||||
FreshHarvestError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -319,13 +326,17 @@ class FreshHarvestActions(BasketMixin):
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, str]) -> str:
|
||||
session = self._client._session # noqa: SLF001 — same package
|
||||
async with session.post(
|
||||
BASE.join(URL(path)),
|
||||
data=payload,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
try:
|
||||
async with session.post(
|
||||
BASE.join(URL(path)),
|
||||
data=payload,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise FreshHarvestError(f"post to {path} failed: {err}") from err
|
||||
|
||||
# ------------------------------------------------------------------ skip
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ USER_AGENT = (
|
||||
"Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Bound every portal request. The shared Home Assistant session otherwise
|
||||
# inherits aiohttp's five-minute default, long enough for one hung request to
|
||||
# drag a refresh — or, during setup, an entire config entry — past Home
|
||||
# Assistant's own timeouts and into a hard failure. Thirty seconds sits far
|
||||
# above the portal's normal response yet still fails fast when it is
|
||||
# unreachable, so a slow site becomes a retry rather than a broken entry.
|
||||
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
|
||||
# Hidden anti-replay fields, minted per session on each GET of the login form.
|
||||
_HIDDEN_RE = re.compile(
|
||||
r"name='(?P<name>LoginSecurity|SubmitToken)'[^>]*value='(?P<value>[^']*)'"
|
||||
@@ -315,11 +323,16 @@ class FreshHarvestClient:
|
||||
self._authenticated = False
|
||||
|
||||
async def _get(self, path: str) -> str:
|
||||
async with self._session.get(
|
||||
BASE.join(URL(path)), headers={"User-Agent": USER_AGENT}
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
try:
|
||||
async with self._session.get(
|
||||
BASE.join(URL(path)),
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise FreshHarvestError(f"request for {path} failed: {err}") from err
|
||||
|
||||
async def async_login(self) -> None:
|
||||
"""Run the two-step handshake: fetch tokens, then post credentials.
|
||||
@@ -347,10 +360,11 @@ class FreshHarvestClient:
|
||||
BASE.join(URL(LOGIN_SUBMIT)),
|
||||
data=payload,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
body = await resp.text()
|
||||
except aiohttp.ClientError as err:
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise FreshHarvestError(f"login request failed: {err}") from err
|
||||
|
||||
if not self._signed_in(body):
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"requirements": [
|
||||
"beautifulsoup4>=4.12"
|
||||
],
|
||||
"version": "0.4.0"
|
||||
"version": "0.4.1"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
@@ -79,13 +80,26 @@ class FreshHarvestBoxSelect(FreshHarvestEntity, SelectEntity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
# Listing costs a fetch per box popup, so it happens once on setup
|
||||
# rather than on every coordinator refresh.
|
||||
# Listing the boxes on offer costs one fetch per box popup. Doing it
|
||||
# here inline blocked entity setup: on a slower connection those fetches
|
||||
# ran past Home Assistant's SLOW_SETUP_MAX_WAIT and the whole config
|
||||
# entry was cancelled into "setup_error", leaving every entity
|
||||
# unavailable despite valid credentials. It now runs once, in the
|
||||
# background — the entity comes up immediately with the current box as
|
||||
# its only option and the rest appear when the listing returns.
|
||||
self.coordinator.config_entry.async_create_background_task(
|
||||
self.hass, self._async_load_options(), f"{DOMAIN}_list_baskets"
|
||||
)
|
||||
|
||||
async def _async_load_options(self) -> None:
|
||||
"""Fetch the switchable boxes once and publish them as options."""
|
||||
try:
|
||||
baskets = await self.coordinator.actions.async_list_baskets()
|
||||
self._options = [b.name for b in baskets]
|
||||
except FreshHarvestError as err:
|
||||
_LOGGER.warning("could not list produce boxes: %s", err)
|
||||
return
|
||||
self._options = [b.name for b in baskets]
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Switch the next delivery to this box."""
|
||||
|
||||
@@ -39,7 +39,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .api import REQUEST_TIMEOUT, FreshHarvestError
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
@@ -129,6 +129,7 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||
"X-Algolia-Application-Id": app,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
hits = (await resp.json()).get("hits") or []
|
||||
|
||||
Reference in New Issue
Block a user