From 5ccdfda7476a9667ee3dadc89be845fae45c1206 Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:39:47 -0500 Subject: [PATCH] Add asyncio-level timeout to Backblaze B2 uploads (#160468) --- .../components/backblaze_b2/backup.py | 31 +++++-- tests/components/backblaze_b2/test_backup.py | 92 +++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py index a9a15e634cf7..f0acc5218bc9 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze_b2/backup.py @@ -36,6 +36,10 @@ _LOGGER = logging.getLogger(__name__) # Cache TTL for backup list (in seconds) CACHE_TTL = 300 +# Timeout for upload operations (in seconds) +# This prevents uploads from hanging indefinitely +UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout) + def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: """Return the suggested filenames for the backup and metadata files.""" @@ -329,13 +333,28 @@ class BackblazeBackupAgent(BackupAgent): _LOGGER.debug("Uploading backup file %s with streaming", filename) try: content_type, _ = mimetypes.guess_type(filename) - file_version = await self._hass.async_add_executor_job( - self._upload_unbound_stream_sync, - reader, - filename, - content_type or "application/x-tar", - file_info, + file_version = await asyncio.wait_for( + self._hass.async_add_executor_job( + self._upload_unbound_stream_sync, + reader, + filename, + content_type or "application/x-tar", + file_info, + ), + timeout=UPLOAD_TIMEOUT, ) + except TimeoutError: + _LOGGER.error( + "Upload of %s timed out after %s seconds", filename, UPLOAD_TIMEOUT + ) + reader.abort() + raise BackupAgentError( + f"Upload timed out after {UPLOAD_TIMEOUT} seconds" + ) from None + except asyncio.CancelledError: + _LOGGER.warning("Upload of %s was cancelled", filename) + reader.abort() + raise finally: reader.close() diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze_b2/test_backup.py index bcf15337bd65..12f5894c49f4 100644 --- a/tests/components/backblaze_b2/test_backup.py +++ b/tests/components/backblaze_b2/test_backup.py @@ -1,5 +1,6 @@ """Backblaze B2 backup agent tests.""" +import asyncio from collections.abc import AsyncGenerator from io import StringIO import json @@ -863,3 +864,94 @@ async def test_metadata_downloads_are_sequential( assert response["success"] # Verify downloads were sequential (max 1 at a time) assert max_concurrent == 1 + + +async def test_upload_timeout( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test upload timeout handling.""" + client = await hass_client() + + mock_file_info = Mock() + mock_file_info.delete = Mock() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch( + "homeassistant.components.backblaze_b2.backup.BackblazeBackupAgent._upload_unbound_stream_sync", + ), + patch( + "homeassistant.components.backblaze_b2.backup.asyncio.wait_for", + side_effect=TimeoutError, + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + return_value=mock_file_info, + ), + caplog.at_level(logging.ERROR), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert any("timed out" in msg for msg in caplog.messages) + + +async def test_upload_cancelled( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test upload cancellation handling.""" + client = await hass_client() + + mock_file_info = Mock() + mock_file_info.delete = Mock() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch( + "homeassistant.components.backblaze_b2.backup.BackblazeBackupAgent._upload_unbound_stream_sync", + ), + patch( + "homeassistant.components.backblaze_b2.backup.asyncio.wait_for", + side_effect=asyncio.CancelledError, + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + return_value=mock_file_info, + ), + caplog.at_level(logging.WARNING), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + # CancelledError propagates up and causes a 500 error + assert resp.status == 500 + assert any("cancelled" in msg for msg in caplog.messages)