[core] Catch body-read errors in download_content

`requests.Response.content` reads the body lazily, so chunked-decode,
gzip-decode, and mid-stream connection drops all surface as
RequestException subclasses on first access -- not from requests.get
itself. The previous code accessed `.content` outside the surrounding
try/except, so any of those (rare but real) errors would propagate
out of download_content instead of falling back to the cached file
or raising the user-friendly cv.Invalid.

Move `data = req.content` inside the try block so the existing error
path handles it. Two new tests cover the with-cache and no-cache
branches using a `ChunkedEncodingError` injected on `.content`.
This commit is contained in:
J. Nick Koston
2026-04-26 10:35:06 -05:00
parent e87e78c544
commit 24a3c2202f
2 changed files with 58 additions and 1 deletions
+5 -1
View File
@@ -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
+53
View File
@@ -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(