Walk only the deliberate exception chain when classifying DNS failures

This commit is contained in:
J. Nick Koston
2026-08-20 16:45:38 -05:00
parent 793f9228f7
commit 130d96e819
2 changed files with 54 additions and 15 deletions
+21 -15
View File
@@ -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,
)
+33
View File
@@ -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)."""