mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 08:50:22 +00:00
[core] Retry transient network errors when downloading external files
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user