mirror of
https://github.com/esphome/esphome.git
synced 2026-06-02 03:02:19 +08:00
[core] Catch body-read errors in download_content (#16023)
This commit is contained in:
@@ -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)"},
|
headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"},
|
||||||
)
|
)
|
||||||
req.raise_for_status()
|
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:
|
except requests.exceptions.RequestException as e:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
@@ -185,7 +190,6 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by
|
|||||||
return path.read_bytes()
|
return path.read_bytes()
|
||||||
raise cv.Invalid(f"Could not download from {url}: {e}") from e
|
raise cv.Invalid(f"Could not download from {url}: {e}") from e
|
||||||
|
|
||||||
data = req.content
|
|
||||||
write_file(path, data)
|
write_file(path, data)
|
||||||
_write_etag(path, req.headers.get(ETAG))
|
_write_etag(path, req.headers.get(ETAG))
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -469,6 +469,69 @@ def test_download_content_with_network_error_no_cache_fails(
|
|||||||
external_files.download_content(url, test_file)
|
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(
|
def test_download_content_skip_external_update_uses_cache(
|
||||||
mock_has_remote_file_changed: MagicMock,
|
mock_has_remote_file_changed: MagicMock,
|
||||||
mock_requests_get: MagicMock,
|
mock_requests_get: MagicMock,
|
||||||
|
|||||||
Reference in New Issue
Block a user