diff --git a/esphome/net_retry.py b/esphome/net_retry.py index 3f4a507418..f783f77607 100644 --- a/esphome/net_retry.py +++ b/esphome/net_retry.py @@ -25,9 +25,21 @@ def _is_permanent_dns_failure(e: BaseException) -> bool: EAI_AGAIN ("temporary failure in name resolution", the flaky container resolver case) stays retryable; only hard resolution failures like - NXDOMAIN count. requests wraps urllib3's MaxRetryError, which carries - the underlying NameResolutionError in its ``reason`` attribute rather - than the __cause__ chain, so attributes and args are walked as well. + NXDOMAIN count. Everything except EAI_AGAIN is treated as permanent: + 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. + + Only the deliberate chain is walked: ``__cause__``, ``args`` (requests + wraps MaxRetryError as ``ConnectionError(e)`` without ``from``) and + urllib3 MaxRetryError's ``reason`` attribute (NameResolutionError never + lands on the cause chain). Implicit ``__context__`` is skipped so a + resolution failure from an unrelated earlier attempt cannot reclassify + an error it did not cause. """ import socket @@ -37,23 +49,17 @@ def _is_permanent_dns_failure(e: BaseException) -> bool: exc = stack.pop() if id(exc) in seen: continue - if isinstance(exc, socket.gaierror): - # 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 + if ( + isinstance(exc, socket.gaierror) + and exc.errno is not None + and exc.errno != socket.EAI_AGAIN + ): + return True seen.add(id(exc)) stack.extend( nxt for nxt in ( exc.__cause__, - exc.__context__, getattr(exc, "reason", None), # urllib3 MaxRetryError *exc.args, ) diff --git a/tests/unit_tests/test_net_retry.py b/tests/unit_tests/test_net_retry.py index 700256d9ff..d915a0ef9b 100644 --- a/tests/unit_tests/test_net_retry.py +++ b/tests/unit_tests/test_net_retry.py @@ -74,6 +74,39 @@ class TestIsTransientDownloadError: assert is_transient_download_error(chained) + def test_implicit_context_does_not_reclassify(self) -> None: + """A resolution failure from an unrelated earlier attempt reaches the + final error only via implicit __context__ (raise inside an except + block); it must not turn a genuine connection reset permanent.""" + try: + try: + raise socket.gaierror(socket.EAI_NONAME, "first attempt") + except socket.gaierror: + raise req.ConnectionError("reset by peer") from None + except req.ConnectionError as reset: + assert reset.__context__ is not None + assert is_transient_download_error(reset) + + def test_gaierror_without_errno_stays_transient(self) -> None: + """A gaierror carrying no EAI code cannot prove a hard failure.""" + chained = req.ConnectionError("resolution failed") + chained.__cause__ = socket.gaierror("no errno") + + assert is_transient_download_error(chained) + + def test_mixed_chain_hard_failure_wins(self) -> None: + """EAI_AGAIN in the chain does not mask a hard failure elsewhere.""" + again = socket.gaierror(socket.EAI_AGAIN, "temporary failure") + hard = socket.gaierror(socket.EAI_NONAME, "unknown host") + + outer = req.ConnectionError(hard) + outer.__cause__ = again + assert not is_transient_download_error(outer) + + outer = req.ConnectionError(again) + outer.__cause__ = hard + assert not is_transient_download_error(outer) + def test_dns_walk_survives_exception_cycles(self) -> None: """A cyclic cause chain must terminate (and stay transient when no resolution failure is present)."""