From 793f9228f7ab662e48c82e515a40494f173ad9c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:29:17 -0500 Subject: [PATCH] Pin the DNS walk against real urllib3 types, state the git.py divergence --- esphome/net_retry.py | 14 +++++++++----- tests/unit_tests/test_net_retry.py | 17 +++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/esphome/net_retry.py b/esphome/net_retry.py index 84b13e5a50..3f4a507418 100644 --- a/esphome/net_retry.py +++ b/esphome/net_retry.py @@ -38,11 +38,15 @@ def _is_permanent_dns_failure(e: BaseException) -> bool: if id(exc) in seen: continue if isinstance(exc, socket.gaierror): - # Everything except EAI_AGAIN is treated as permanent. Codes - # like EAI_NONAME can occur during a brief network outage too, - # but a 6s backoff rarely outlives one, and permanent means - # callers fall back to their cache immediately instead of - # sleeping first (the offline-build case). + # Everything except EAI_AGAIN is treated as permanent. This is + # deliberately narrower than git.py, which retries NXDOMAIN + # too: codes like EAI_NONAME can occur during a brief network + # outage, but a 6s backoff rarely outlives one, and permanent + # means callers with a cached copy fall back to it immediately + # instead of sleeping first (the offline-build case). The + # trade-off is that a hard resolver failure on a first + # download fails without retrying, same as before retries + # existed. return exc.errno != socket.EAI_AGAIN seen.add(id(exc)) stack.extend( diff --git a/tests/unit_tests/test_net_retry.py b/tests/unit_tests/test_net_retry.py index 313e907d4b..700256d9ff 100644 --- a/tests/unit_tests/test_net_retry.py +++ b/tests/unit_tests/test_net_retry.py @@ -43,18 +43,23 @@ class TestIsTransientDownloadError: 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.""" + from urllib3.exceptions import MaxRetryError, NameResolutionError + 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)) + # The real urllib3 shape, raised the way urllib3 raises it: the + # gaierror is NameResolutionError.__cause__ (set at the raise + # site), which MaxRetryError carries in its reason attribute. + try: + raise NameResolutionError("example.invalid", None, gai) from gai + except NameResolutionError as nre: + wrapped = req.ConnectionError( + MaxRetryError(None, "http://example.invalid/", reason=nre) + ) assert not is_transient_download_error(wrapped) # A garden-variety connection reset stays transient.