Improve imap error handling for config entry (#96724)
* Improve error handling config entry * Removed CancelledError * Add cleanup * Do not call protected async_set_state()
This commit is contained in:
parent
3a06659120
commit
65ebb6a74f
2 changed files with 74 additions and 12 deletions
|
@ -13,7 +13,7 @@ from typing import Any
|
||||||
from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException
|
from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException
|
||||||
import async_timeout
|
import async_timeout
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
|
@ -54,6 +54,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
BACKOFF_TIME = 10
|
BACKOFF_TIME = 10
|
||||||
|
|
||||||
EVENT_IMAP = "imap_content"
|
EVENT_IMAP = "imap_content"
|
||||||
|
MAX_ERRORS = 3
|
||||||
MAX_EVENT_DATA_BYTES = 32168
|
MAX_EVENT_DATA_BYTES = 32168
|
||||||
|
|
||||||
|
|
||||||
|
@ -174,6 +175,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initiate imap client."""
|
"""Initiate imap client."""
|
||||||
self.imap_client = imap_client
|
self.imap_client = imap_client
|
||||||
|
self.auth_errors: int = 0
|
||||||
self._last_message_id: str | None = None
|
self._last_message_id: str | None = None
|
||||||
self.custom_event_template = None
|
self.custom_event_template = None
|
||||||
_custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE)
|
_custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE)
|
||||||
|
@ -315,7 +317,9 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
async def _async_update_data(self) -> int | None:
|
async def _async_update_data(self) -> int | None:
|
||||||
"""Update the number of unread emails."""
|
"""Update the number of unread emails."""
|
||||||
try:
|
try:
|
||||||
return await self._async_fetch_number_of_messages()
|
messages = await self._async_fetch_number_of_messages()
|
||||||
|
self.auth_errors = 0
|
||||||
|
return messages
|
||||||
except (
|
except (
|
||||||
AioImapException,
|
AioImapException,
|
||||||
UpdateFailed,
|
UpdateFailed,
|
||||||
|
@ -330,8 +334,15 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
self.async_set_update_error(ex)
|
self.async_set_update_error(ex)
|
||||||
raise ConfigEntryError("Selected mailbox folder is invalid.") from ex
|
raise ConfigEntryError("Selected mailbox folder is invalid.") from ex
|
||||||
except InvalidAuth as ex:
|
except InvalidAuth as ex:
|
||||||
_LOGGER.warning("Username or password incorrect, starting reauthentication")
|
|
||||||
await self._cleanup()
|
await self._cleanup()
|
||||||
|
self.auth_errors += 1
|
||||||
|
if self.auth_errors <= MAX_ERRORS:
|
||||||
|
_LOGGER.warning("Authentication failed, retrying")
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Username or password incorrect, starting reauthentication"
|
||||||
|
)
|
||||||
|
self.config_entry.async_start_reauth(self.hass)
|
||||||
self.async_set_update_error(ex)
|
self.async_set_update_error(ex)
|
||||||
raise ConfigEntryAuthFailed() from ex
|
raise ConfigEntryAuthFailed() from ex
|
||||||
|
|
||||||
|
@ -359,27 +370,28 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
|
|
||||||
async def _async_wait_push_loop(self) -> None:
|
async def _async_wait_push_loop(self) -> None:
|
||||||
"""Wait for data push from server."""
|
"""Wait for data push from server."""
|
||||||
|
cleanup = False
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
number_of_messages = await self._async_fetch_number_of_messages()
|
number_of_messages = await self._async_fetch_number_of_messages()
|
||||||
except InvalidAuth as ex:
|
except InvalidAuth as ex:
|
||||||
|
self.auth_errors += 1
|
||||||
await self._cleanup()
|
await self._cleanup()
|
||||||
_LOGGER.warning(
|
if self.auth_errors <= MAX_ERRORS:
|
||||||
"Username or password incorrect, starting reauthentication"
|
_LOGGER.warning("Authentication failed, retrying")
|
||||||
)
|
else:
|
||||||
self.config_entry.async_start_reauth(self.hass)
|
_LOGGER.warning(
|
||||||
|
"Username or password incorrect, starting reauthentication"
|
||||||
|
)
|
||||||
|
self.config_entry.async_start_reauth(self.hass)
|
||||||
self.async_set_update_error(ex)
|
self.async_set_update_error(ex)
|
||||||
await asyncio.sleep(BACKOFF_TIME)
|
await asyncio.sleep(BACKOFF_TIME)
|
||||||
except InvalidFolder as ex:
|
except InvalidFolder as ex:
|
||||||
_LOGGER.warning("Selected mailbox folder is invalid")
|
_LOGGER.warning("Selected mailbox folder is invalid")
|
||||||
await self._cleanup()
|
await self._cleanup()
|
||||||
self.config_entry._async_set_state( # pylint: disable=protected-access
|
|
||||||
self.hass,
|
|
||||||
ConfigEntryState.SETUP_ERROR,
|
|
||||||
"Selected mailbox folder is invalid.",
|
|
||||||
)
|
|
||||||
self.async_set_update_error(ex)
|
self.async_set_update_error(ex)
|
||||||
await asyncio.sleep(BACKOFF_TIME)
|
await asyncio.sleep(BACKOFF_TIME)
|
||||||
|
continue
|
||||||
except (
|
except (
|
||||||
UpdateFailed,
|
UpdateFailed,
|
||||||
AioImapException,
|
AioImapException,
|
||||||
|
@ -390,6 +402,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
await asyncio.sleep(BACKOFF_TIME)
|
await asyncio.sleep(BACKOFF_TIME)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
self.auth_errors = 0
|
||||||
self.async_set_updated_data(number_of_messages)
|
self.async_set_updated_data(number_of_messages)
|
||||||
try:
|
try:
|
||||||
idle: asyncio.Future = await self.imap_client.idle_start()
|
idle: asyncio.Future = await self.imap_client.idle_start()
|
||||||
|
@ -398,6 +411,10 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
async with async_timeout.timeout(10):
|
async with async_timeout.timeout(10):
|
||||||
await idle
|
await idle
|
||||||
|
|
||||||
|
# From python 3.11 asyncio.TimeoutError is an alias of TimeoutError
|
||||||
|
except asyncio.CancelledError as ex:
|
||||||
|
cleanup = True
|
||||||
|
raise asyncio.CancelledError from ex
|
||||||
except (AioImapException, asyncio.TimeoutError):
|
except (AioImapException, asyncio.TimeoutError):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Lost %s (will attempt to reconnect after %s s)",
|
"Lost %s (will attempt to reconnect after %s s)",
|
||||||
|
@ -406,6 +423,9 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
)
|
)
|
||||||
await self._cleanup()
|
await self._cleanup()
|
||||||
await asyncio.sleep(BACKOFF_TIME)
|
await asyncio.sleep(BACKOFF_TIME)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
await self._cleanup()
|
||||||
|
|
||||||
async def shutdown(self, *_: Any) -> None:
|
async def shutdown(self, *_: Any) -> None:
|
||||||
"""Close resources."""
|
"""Close resources."""
|
||||||
|
|
|
@ -235,6 +235,48 @@ async def test_initial_invalid_folder_error(
|
||||||
assert (state is not None) == success
|
assert (state is not None) == success
|
||||||
|
|
||||||
|
|
||||||
|
@patch("homeassistant.components.imap.coordinator.MAX_ERRORS", 1)
|
||||||
|
@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"])
|
||||||
|
async def test_late_authentication_retry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
mock_imap_protocol: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test retrying authentication after a search was failed."""
|
||||||
|
|
||||||
|
# Mock an error in waiting for a pushed update
|
||||||
|
mock_imap_protocol.wait_server_push.side_effect = AioImapException(
|
||||||
|
"Something went wrong"
|
||||||
|
)
|
||||||
|
|
||||||
|
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, utcnow() + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Mock that the search fails, this will trigger
|
||||||
|
# that the connection will be restarted
|
||||||
|
# Then fail selecting the folder
|
||||||
|
mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE)
|
||||||
|
mock_imap_protocol.login.side_effect = Response(*BAD_RESPONSE)
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, utcnow() + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, utcnow() + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert "Authentication failed, retrying" in caplog.text
|
||||||
|
|
||||||
|
# we still should have an entity with an unavailable state
|
||||||
|
state = hass.states.get("sensor.imap_email_email_com")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
@patch("homeassistant.components.imap.coordinator.MAX_ERRORS", 0)
|
||||||
@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"])
|
@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"])
|
||||||
async def test_late_authentication_error(
|
async def test_late_authentication_error(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue