diff --git a/scripts/upstream.py b/scripts/upstream.py index d16cd078..87a2eb5a 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -208,12 +208,29 @@ def _host_is_unresolvable(exc: BaseException) -> bool: if seen is None: break if isinstance(seen, socket.gaierror): - return True + # A resolver that answers nothing at all is the local network: + # every profile would read as gone, and the sweep would report + # nothing to review while it reviewed nothing. + return seen.errno == socket.EAI_NONAME and _resolver_answers() nested = getattr(seen, "reason", None) seen = nested if isinstance(nested, BaseException) else seen.__cause__ return False +# A name every forge call depends on: if it does not resolve, the fault is +# local and no host can be declared gone. +RESOLVER_PROBE = "github.com" + + +def _resolver_answers() -> bool: + """Whether name resolution works here at all. Replaced in tests.""" + try: + socket.getaddrinfo(RESOLVER_PROBE, 443) + except OSError: + return False + return True + + def _fetch(url: str, accept_json: bool = False) -> bytes | None: """Body of a GET, or None on 404. diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 48972e2e..0fec7a16 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -9,6 +9,7 @@ import sys import tempfile import socket import unittest +from unittest import mock import urllib.error import urllib.request from pathlib import Path @@ -291,14 +292,26 @@ class TestGoneUpstream(unittest.TestCase): self.assertEqual(self.calls, 1) def test_unresolvable_host_is_gone_and_not_retried(self): - self._serve( - urllib.error.URLError(socket.gaierror(-2, "Name or service not known")) - ) - with self.assertRaises(upstream.GoneError): - upstream._fetch("https://host/x") + self._serve(urllib.error.URLError( + socket.gaierror(socket.EAI_NONAME, "Name or service not known") + )) + with mock.patch.object(upstream, "_resolver_answers", return_value=True): + with self.assertRaises(upstream.GoneError): + upstream._fetch("https://host/x") self.assertEqual(self.calls, 1) self.assertEqual(self.slept, []) + def test_a_local_resolver_outage_is_not_gone(self): + """With DNS down here, every forge looked gone and profile_sync + listed nothing to review, exiting 0.""" + for errno, resolver in ((socket.EAI_NONAME, False), (socket.EAI_AGAIN, True)): + with self.subTest(errno=errno, resolver=resolver): + self._serve(urllib.error.URLError(socket.gaierror(errno, "no"))) + with mock.patch.object(upstream, "_resolver_answers", return_value=resolver): + with self.assertRaises(upstream.UpstreamError) as caught: + upstream._fetch("https://host/x") + self.assertNotIsInstance(caught.exception, upstream.GoneError) + def test_a_gone_error_is_still_an_upstream_error(self): self._serve(_http_error(451)) with self.assertRaises(upstream.UpstreamError):