Improve error handling in CoreBackupReaderWriter (#139508)

This commit is contained in:
Erik Montnemery 2025-02-28 14:25:35 +01:00 committed by GitHub
parent 030a1460de
commit 228a4eb391
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 61 additions and 12 deletions

View File

@ -14,6 +14,7 @@ from itertools import chain
import json import json
from pathlib import Path, PurePath from pathlib import Path, PurePath
import shutil import shutil
import sys
import tarfile import tarfile
import time import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
@ -308,6 +309,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
_message = "On-the-fly decryption is not supported for this backup." _message = "On-the-fly decryption is not supported for this backup."
class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
"""Raised when multiple exceptions occur."""
error_code = "multiple_errors"
class BackupManager: class BackupManager:
"""Define the format that backup managers can have.""" """Define the format that backup managers can have."""
@ -1605,10 +1612,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
) )
finally: finally:
# Inform integrations the backup is done # Inform integrations the backup is done
# If there's an unhandled exception, we keep it so we can rethrow it in case
# the post backup actions also fail.
unhandled_exc = sys.exception()
try: try:
await manager.async_post_backup_actions() try:
except BackupManagerError as err: await manager.async_post_backup_actions()
raise BackupReaderWriterError(str(err)) from err except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
except Exception as err:
if not unhandled_exc:
raise
# If there's an unhandled exception, we wrap both that and the exception
# from the post backup actions in an ExceptionGroup so the caller is
# aware of both exceptions.
raise BackupManagerExceptionGroup(
f"Multiple errors when creating backup: {unhandled_exc}, {err}",
[unhandled_exc, err],
) from None
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(
self, self,

View File

@ -8,6 +8,7 @@ from dataclasses import replace
from io import StringIO from io import StringIO
import json import json
from pathlib import Path from pathlib import Path
import re
import tarfile import tarfile
from typing import Any from typing import Any
from unittest.mock import ( from unittest.mock import (
@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError
from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import ( from homeassistant.components.backup.manager import (
BackupManagerError, BackupManagerError,
BackupManagerExceptionGroup,
BackupManagerState, BackupManagerState,
CreateBackupStage, CreateBackupStage,
CreateBackupState, CreateBackupState,
@ -1646,34 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None:
assert str(err.value) == "Error during pre-backup: Test exception" assert str(err.value) == "Error during pre-backup: Test exception"
@pytest.mark.parametrize(
("unhandled_error", "expected_exception", "expected_msg"),
[
(None, BackupManagerError, "Error during post-backup: Test exception"),
(
HomeAssistantError("Boom"),
BackupManagerExceptionGroup,
(
"Multiple errors when creating backup: Error during pre-backup: Boom, "
"Error during post-backup: Test exception (2 sub-exceptions)"
),
),
(
Exception("Boom"),
BackupManagerExceptionGroup,
(
"Multiple errors when creating backup: Error during pre-backup: Boom, "
"Error during post-backup: Test exception (2 sub-exceptions)"
),
),
],
)
@pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.usefixtures("mock_backup_generation")
async def test_exception_platform_post(hass: HomeAssistant) -> None: async def test_exception_platform_post(
hass: HomeAssistant,
unhandled_error: Exception | None,
expected_exception: type[Exception],
expected_msg: str,
) -> None:
"""Test exception in post step.""" """Test exception in post step."""
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
remote_agent = mock_backup_agent("remote") remote_agent = mock_backup_agent("remote")
await setup_backup_platform( await setup_backup_platform(
hass, hass,
domain="test", domain="test",
platform=Mock( platform=Mock(
async_pre_backup=AsyncMock(), # We let the pre_backup fail to test that unhandled errors are not discarded
async_post_backup=_mock_step, # when post backup fails
async_pre_backup=AsyncMock(side_effect=unhandled_error),
async_post_backup=AsyncMock(
side_effect=HomeAssistantError("Test exception")
),
async_get_backup_agents=AsyncMock(return_value=[remote_agent]), async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
), ),
) )
await setup_backup_integration(hass) await setup_backup_integration(hass)
with pytest.raises(BackupManagerError) as err: with pytest.raises(expected_exception, match=re.escape(expected_msg)):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
"create", "create",
blocking=True, blocking=True,
) )
assert str(err.value) == "Error during post-backup: Test exception"
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (