diff --git a/esphome/external_files.py b/esphome/external_files.py index fbc261f8e0e..dfabc54f474 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -175,6 +175,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( @@ -185,7 +190,6 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by return path.read_bytes() raise cv.Invalid(f"Could not download from {url}: {e}") from e - data = req.content write_file(path, data) _write_etag(path, req.headers.get(ETAG)) return data diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index c894f906662..64ef1495817 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -469,6 +469,69 @@ def test_download_content_with_network_error_no_cache_fails( external_files.download_content(url, test_file) +class _BodyReadErrorResponse: + """Stand-in for `requests.Response` whose `.content` raises on access. + + A small dedicated stub avoids mutating `MagicMock`'s class with a + `property` (which would leak across every other MagicMock-based test + in this file). + """ + + def __init__(self, exc: Exception) -> None: + self._exc = exc + self.headers: dict[str, str] = {} + + def raise_for_status(self) -> None: + return None + + @property + def content(self) -> bytes: + raise self._exc + + +def test_download_content_with_body_read_error_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_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_remote_file_changed.return_value = True + mock_requests_get.return_value = _BodyReadErrorResponse( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + + result = external_files.download_content("https://example.com/file.txt", test_file) + + assert result == cached_content + + +def test_download_content_with_body_read_error_no_cache_fails( + mock_has_remote_file_changed: MagicMock, + mock_requests_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_remote_file_changed.return_value = True + mock_requests_get.return_value = _BodyReadErrorResponse( + requests.exceptions.ChunkedEncodingError("body truncated") + ) + + with pytest.raises(Invalid, match="Could not download from.*body truncated"): + external_files.download_content("https://example.com/file.txt", test_file) + + def test_download_content_skip_external_update_uses_cache( mock_has_remote_file_changed: MagicMock, mock_requests_get: MagicMock,