diff --git a/esphome/external_files.py b/esphome/external_files.py index b6f6149ebb..1dd7ce16b8 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -102,6 +102,11 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"}, ) req.raise_for_status() + # `.content` reads the body lazily; chunked-decode, gzip-decode, + # and mid-stream connection errors all surface here as + # RequestException subclasses, so this needs the same fall-back + # treatment as the request itself. + data = req.content except requests.exceptions.RequestException as e: if path.exists(): _LOGGER.warning( @@ -113,6 +118,5 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by raise cv.Invalid(f"Could not download from {url}: {e}") from e path.parent.mkdir(parents=True, exist_ok=True) - data = req.content path.write_bytes(data) return data diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 4b0826db04..f485316097 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -238,6 +238,59 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_with_body_read_error_uses_cache( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """Body-read errors (chunked-decode/gzip-decode/mid-stream connection + drop) raise RequestException subclasses on `.content` access, not from + `requests.get` itself. They must follow the same fall-back-to-cache + path as a connect-time failure. + """ + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + type(mock_response).content = property( + lambda self: (_ for _ in ()).throw( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + ) + mock_get.return_value = mock_response + + result = external_files.download_content("https://example.com/file.txt", test_file) + + assert result == cached_content + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_with_body_read_error_no_cache_fails( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """A body-read failure with no cache available must surface as a + cv.Invalid, same as a connect-time failure with no cache. + """ + test_file = setup_core / "nonexistent.txt" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + type(mock_response).content = property( + lambda self: (_ for _ in ()).throw( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + ) + mock_get.return_value = mock_response + + with pytest.raises(Invalid, match="Could not download from.*body truncated"): + external_files.download_content("https://example.com/file.txt", test_file) + + @patch("esphome.external_files.requests.get") @patch("esphome.external_files.has_remote_file_changed") def test_download_content_skip_external_update_uses_cache(