mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Walk only the deliberate exception chain when classifying DNS failures
This commit is contained in:
+21
-15
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user