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:
Jan Bouwhuis 2023-07-17 09:44:47 +02:00 committed by GitHub
parent 3a06659120
commit 65ebb6a74f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 74 additions and 12 deletions

View file

@ -13,7 +13,7 @@ from typing import Any
from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException
import async_timeout
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
@ -54,6 +54,7 @@ _LOGGER = logging.getLogger(__name__)
BACKOFF_TIME = 10
EVENT_IMAP = "imap_content"
MAX_ERRORS = 3
MAX_EVENT_DATA_BYTES = 32168
@ -174,6 +175,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
) -> None:
"""Initiate imap client."""
self.imap_client = imap_client
self.auth_errors: int = 0
self._last_message_id: str | None = None
self.custom_event_template = None
_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:
"""Update the number of unread emails."""
try:
return await self._async_fetch_number_of_messages()
messages = await self._async_fetch_number_of_messages()
self.auth_errors = 0
return messages
except (
AioImapException,
UpdateFailed,
@ -330,8 +334,15 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
self.async_set_update_error(ex)
raise ConfigEntryError("Selected mailbox folder is invalid.") from ex
except InvalidAuth as ex:
_LOGGER.warning("Username or password incorrect, starting reauthentication")
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)
raise ConfigEntryAuthFailed() from ex
@ -359,27 +370,28 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
async def _async_wait_push_loop(self) -> None:
"""Wait for data push from server."""
cleanup = False
while True:
try:
number_of_messages = await self._async_fetch_number_of_messages()
except InvalidAuth as ex:
self.auth_errors += 1
await self._cleanup()
_LOGGER.warning(
"Username or password incorrect, starting reauthentication"
)
self.config_entry.async_start_reauth(self.hass)
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)
await asyncio.sleep(BACKOFF_TIME)
except InvalidFolder as ex:
_LOGGER.warning("Selected mailbox folder is invalid")
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)
await asyncio.sleep(BACKOFF_TIME)
continue
except (
UpdateFailed,
AioImapException,
@ -390,6 +402,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
await asyncio.sleep(BACKOFF_TIME)
continue
else:
self.auth_errors = 0
self.async_set_updated_data(number_of_messages)
try:
idle: asyncio.Future = await self.imap_client.idle_start()
@ -398,6 +411,10 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
async with async_timeout.timeout(10):
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):
_LOGGER.debug(
"Lost %s (will attempt to reconnect after %s s)",
@ -406,6 +423,9 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
)
await self._cleanup()
await asyncio.sleep(BACKOFF_TIME)
finally:
if cleanup:
await self._cleanup()
async def shutdown(self, *_: Any) -> None:
"""Close resources."""

View file

@ -235,6 +235,48 @@ async def test_initial_invalid_folder_error(
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"])
async def test_late_authentication_error(
hass: HomeAssistant,