[core] Retry transient network errors when downloading external files

This commit is contained in:
J. Nick Koston
2026-08-20 00:25:14 -05:00
parent 29404a782c
commit d276f2b490
6 changed files with 374 additions and 68 deletions
+137 -1
View File
@@ -4,7 +4,7 @@ import os
from pathlib import Path
import time
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
import pytest
import requests
@@ -81,6 +81,17 @@ def mock_download_content_many() -> MagicMock:
yield m
@pytest.fixture
def mock_retry_sleep() -> MagicMock:
"""Patch the retry backoff sleep (module-global time.sleep, so the
blast radius is the whole process) for tests that inject transient
network errors; without it they would really wait 2s/4s per retry.
Retry tests assert on this mock's call args.
"""
with patch("esphome.net_retry.time.sleep") as m:
yield m
def test_compute_local_file_dir(setup_core: Path) -> None:
"""Test compute_local_file_dir creates and returns correct path."""
domain = "font"
@@ -495,6 +506,7 @@ class _BodyReadErrorResponse:
def test_download_content_with_body_read_error_uses_cache(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Body-read errors (chunked-decode/gzip-decode/mid-stream connection
@@ -519,6 +531,7 @@ def test_download_content_with_body_read_error_uses_cache(
def test_download_content_with_body_read_error_no_cache_fails(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""A body-read failure with no cache available must surface as a
@@ -535,6 +548,129 @@ def test_download_content_with_body_read_error_no_cache_fails(
external_files.download_content("https://example.com/file.txt", test_file)
def test_download_content_retries_transient_error_then_succeeds(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Transient failures (connection reset, timeout) are retried with 2s/4s
backoff before giving up; a late success downloads normally."""
test_file = setup_core / "downloads" / "file.txt"
mock_has_remote_file_changed.return_value = True
ok = MagicMock()
ok.content = b"downloaded"
ok.headers = {}
mock_requests_get.side_effect = [
requests.exceptions.ConnectionError("reset by peer"),
requests.exceptions.Timeout("timed out"),
ok,
]
result = external_files.download_content("https://example.com/file.txt", test_file)
assert result == b"downloaded"
assert test_file.read_bytes() == b"downloaded"
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
def test_download_content_transient_error_exhausts_attempts(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""A persistent transient failure gives up after three attempts and then
follows the normal no-cache error path."""
test_file = setup_core / "nonexistent.txt"
mock_has_remote_file_changed.return_value = True
mock_requests_get.side_effect = requests.exceptions.ConnectionError("reset by peer")
with pytest.raises(Invalid, match="Could not download from.*reset by peer"):
external_files.download_content("https://example.com/file.txt", test_file)
assert mock_retry_sleep.call_args_list == [call(2), call(4)]
def test_download_content_non_transient_error_not_retried(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Permanent failures like a 404 fail on the first attempt."""
test_file = setup_core / "nonexistent.txt"
mock_has_remote_file_changed.return_value = True
response = MagicMock()
response.status_code = 404
mock_requests_get.side_effect = requests.exceptions.HTTPError(
"404 Client Error", response=response
)
with pytest.raises(Invalid, match="Could not download from.*404"):
external_files.download_content("https://example.com/file.txt", test_file)
assert mock_requests_get.call_count == 1
mock_retry_sleep.assert_not_called()
def test_download_content_retries_body_read_error(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""Mid-stream failures surfacing from `.content` are retried too."""
test_file = setup_core / "downloads" / "file.txt"
mock_has_remote_file_changed.return_value = True
ok = MagicMock()
ok.content = b"downloaded"
ok.headers = {}
mock_requests_get.side_effect = [
_BodyReadErrorResponse(
requests.exceptions.ChunkedEncodingError("body truncated")
),
ok,
]
result = external_files.download_content("https://example.com/file.txt", test_file)
assert result == b"downloaded"
assert mock_requests_get.call_count == 2
assert mock_retry_sleep.call_args_list == [call(2)]
def test_has_remote_file_changed_retries_transient_error(
mock_requests_head: MagicMock,
mock_retry_sleep: MagicMock,
setup_core: Path,
) -> None:
"""A HEAD revalidation that fails transiently then returns 304 does not
mark the cached copy stale."""
test_file = setup_core / "cached.txt"
test_file.write_bytes(b"cached content")
ok = MagicMock()
ok.status_code = 304
ok.headers = {}
mock_requests_head.side_effect = [
requests.exceptions.ConnectionError("reset by peer"),
ok,
]
changed = external_files.has_remote_file_changed(
"https://example.com/file.txt", test_file
)
assert changed is False
assert test_file not in external_files._run_data().stale_paths
assert mock_requests_head.call_count == 2
assert mock_retry_sleep.call_args_list == [call(2)]
def test_download_content_skip_external_update_uses_cache(
mock_has_remote_file_changed: MagicMock,
mock_requests_get: MagicMock,
@@ -23,7 +23,6 @@ from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
_detect_archive_root,
_is_transient_download_error,
_rename_with_retry,
_tar_extract_all,
_zip_extract_all,
@@ -1594,43 +1593,6 @@ class TestDownloadFromMirrors:
mock_sleep.assert_not_called()
def _http_error(status: int) -> req.HTTPError:
"""An HTTPError carrying a response with the given status, as raised by
``raise_for_status`` on a real response."""
resp = MagicMock()
resp.status_code = status
return req.HTTPError(str(status), response=resp)
class TestIsTransientDownloadError:
def test_connection_errors_are_transient(self) -> None:
assert _is_transient_download_error(req.ConnectionError("reset"))
assert _is_transient_download_error(req.Timeout("timed out"))
assert _is_transient_download_error(
req.exceptions.ChunkedEncodingError("dropped")
)
def test_http_statuses(self) -> None:
assert not _is_transient_download_error(_http_error(404))
assert not _is_transient_download_error(_http_error(403))
assert _is_transient_download_error(_http_error(429))
assert _is_transient_download_error(_http_error(503))
def test_http_error_without_response_is_permanent(self) -> None:
assert not _is_transient_download_error(req.HTTPError("boom"))
def test_exhausted_resume_attempts_are_permanent(self) -> None:
"""download_with_resume already spent its own resume attempts; its
EsphomeError wrapper is not retried again at the sweep level."""
wrapped = EsphomeError("Failed to download after 3 attempts")
wrapped.__cause__ = req.ConnectionError("down")
assert not _is_transient_download_error(wrapped)
def test_unrelated_errors_are_permanent(self) -> None:
assert not _is_transient_download_error(OSError("disk full"))
assert not _is_transient_download_error(EsphomeError("size mismatch"))
def test_importing_framework_helpers_does_not_import_requests() -> None:
"""Importing framework_helpers must not drag in requests.
+111
View File
@@ -0,0 +1,111 @@
"""Tests for esphome.net_retry."""
import socket
from unittest.mock import MagicMock, call, patch
import pytest
import requests as req
from esphome.core import EsphomeError
from esphome.net_retry import fetch_with_retry, is_transient_download_error
def _http_error(status: int) -> req.HTTPError:
"""An HTTPError carrying a response with the given status, as raised by
``raise_for_status`` on a real response."""
resp = MagicMock()
resp.status_code = status
return req.HTTPError(str(status), response=resp)
class TestIsTransientDownloadError:
def test_connection_errors_are_transient(self) -> None:
assert is_transient_download_error(req.ConnectionError("reset"))
assert is_transient_download_error(req.Timeout("timed out"))
assert is_transient_download_error(
req.exceptions.ChunkedEncodingError("dropped")
)
assert is_transient_download_error(
req.exceptions.ContentDecodingError("gzip stream truncated")
)
def test_http_statuses(self) -> None:
assert not is_transient_download_error(_http_error(404))
assert not is_transient_download_error(_http_error(403))
assert is_transient_download_error(_http_error(429))
assert is_transient_download_error(_http_error(503))
def test_http_error_without_response_is_permanent(self) -> None:
assert not is_transient_download_error(req.HTTPError("boom"))
def test_hard_dns_failures_are_permanent(self) -> None:
"""Hard name resolution failures won't heal within a retry window;
offline builds must fall back to their cache without sleeping first.
requests can surface the gaierror via the cause chain or via
urllib3's MaxRetryError.reason attribute."""
gai = socket.gaierror(socket.EAI_NONAME, "nodename nor servname provided")
chained = req.ConnectionError("resolution failed")
chained.__cause__ = gai
assert not is_transient_download_error(chained)
class _FakeMaxRetryError(Exception):
def __init__(self, reason: BaseException) -> None:
super().__init__("max retries exceeded")
self.reason = reason
wrapped = req.ConnectionError(_FakeMaxRetryError(gai))
assert not is_transient_download_error(wrapped)
# A garden-variety connection reset stays transient.
assert is_transient_download_error(req.ConnectionError("reset by peer"))
def test_temporary_dns_failure_stays_transient(self) -> None:
"""EAI_AGAIN is a temporary resolver failure (flaky container DNS)
and does heal, matching git.py's policy of retrying DNS flakes."""
gai = socket.gaierror(socket.EAI_AGAIN, "temporary failure in name resolution")
chained = req.ConnectionError("resolution failed")
chained.__cause__ = gai
assert is_transient_download_error(chained)
def test_dns_walk_survives_exception_cycles(self) -> None:
"""A cyclic cause chain must terminate (and stay transient when no
resolution failure is present)."""
outer = req.ConnectionError("a")
inner = ValueError("b")
outer.__cause__ = inner
inner.__cause__ = outer
assert is_transient_download_error(outer)
def test_exhausted_resume_attempts_are_permanent(self) -> None:
"""download_with_resume already spent its own resume attempts; its
EsphomeError wrapper is not retried again at the sweep level."""
wrapped = EsphomeError("Failed to download after 3 attempts")
wrapped.__cause__ = req.ConnectionError("down")
assert not is_transient_download_error(wrapped)
def test_unrelated_errors_are_permanent(self) -> None:
assert not is_transient_download_error(OSError("disk full"))
assert not is_transient_download_error(EsphomeError("size mismatch"))
class TestFetchWithRetry:
def test_logs_the_upcoming_attempt_number(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""The warning names the attempt about to run, not the one that just
failed, so a user sees attempt 2/3 and 3/3 before the final failure."""
with (
patch("esphome.net_retry.time.sleep") as mock_sleep,
pytest.raises(req.ConnectionError),
):
fetch_with_retry(
"https://example.com/f",
lambda: (_ for _ in ()).throw(req.ConnectionError("reset")),
)
assert mock_sleep.call_args_list == [call(2), call(4)]
assert "(attempt 2/3)" in caplog.text
assert "(attempt 3/3)" in caplog.text