From dae134ddd1940cb9e6678d2394ed16238837bb34 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Thu, 13 Aug 2026 10:19:18 -0400 Subject: [PATCH] Test DNS address fallback and contact-host recovery --- tests/unit/test_async_manager.py | 28 +++++++++ tests/unit/test_http.py | 103 +++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/tests/unit/test_async_manager.py b/tests/unit/test_async_manager.py index 7d1d4c7..e4b643d 100644 --- a/tests/unit/test_async_manager.py +++ b/tests/unit/test_async_manager.py @@ -230,6 +230,34 @@ async def mock_fetch(url: str) -> list[str]: "http://[2001:db8::1]:8000/localnodes", ] + @pytest.mark.asyncio + async def test_refresh_recovers_through_original_dns_entrypoint(self) -> None: + """Failed refresh retains nodes, then recovers through logical seed host.""" + config = Config(seed_hosts=["entrypoint.test"], port=8000) + calls: list[str] = [] + + async def mock_fetch(url: str) -> list[str]: + calls.append(url) + if len(calls) == 1: + return ["old-node.test"] + if len(calls) == 2: + return [] + return ["new-node.test"] + + manager = AsyncLiveNodesManager(config, mock_fetch) + + assert await manager.refresh_nodes() is True + assert manager.nodes.nodes == ("old-node.test",) + assert await manager.refresh_nodes() is False + assert manager.nodes.nodes == ("old-node.test",) + assert await manager.refresh_nodes() is True + assert manager.nodes.nodes == ("new-node.test",) + assert calls == [ + "http://entrypoint.test:8000/localnodes", + "http://entrypoint.test:8000/localnodes", + "http://entrypoint.test:8000/localnodes", + ] + @pytest.mark.asyncio async def test_url_construction_with_dc_scope(self, config: Config) -> None: """Test URL construction with datacenter scope.""" diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index e0ac01f..96dff9d 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -332,6 +332,56 @@ def test_dual_stack_dns_falls_back_from_broken_ipv4_to_ipv6(self) -> None: assert list(nodes) == ["::1"] + def test_dns_falls_back_after_several_broken_ipv4_addresses(self) -> None: + """The sync resolver advances past several broken IPv4 records.""" + MockHTTPHandler.response_data = ["127.0.0.1"] + MockHTTPHandler.response_code = 200 + server = HTTPServer(("127.0.0.1", 0), MockHTTPHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + records = [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.2", server.server_port), + ), + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.3", server.server_port), + ), + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", server.server_port), + ), + ] + + try: + with ( + patch.dict(os.environ, {"NO_PROXY": "*", "no_proxy": "*"}), + patch("socket.getaddrinfo", return_value=records) as resolve, + ): + nodes = create_sync_http_fetcher(timeout_seconds=1.0)( + f"http://entrypoint.test:{server.server_port}/localnodes" + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert resolve.call_args.args[:2] == ( + "entrypoint.test", + server.server_port, + ) + assert list(nodes) == ["127.0.0.1"] + def test_dual_stack_dns_all_records_unavailable_fails_clearly(self) -> None: """Exhausted IPv4 and IPv6 records return without hanging.""" records = [ @@ -458,6 +508,59 @@ async def test_dual_stack_dns_falls_back_from_broken_ipv4_to_ipv6( assert list(nodes) == ["::1"] + @pytest.mark.asyncio + async def test_dns_falls_back_after_several_broken_ipv4_addresses( + self, + ) -> None: + """The aiohttp resolver advances past several broken IPv4 records.""" + pytest.importorskip("aiohttp") + MockHTTPHandler.response_data = ["127.0.0.1"] + MockHTTPHandler.response_code = 200 + server = HTTPServer(("127.0.0.1", 0), MockHTTPHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + records = [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.2", server.server_port), + ), + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.3", server.server_port), + ), + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", server.server_port), + ), + ] + fetcher = AsyncNodeFetcher(timeout_seconds=1.0) + + try: + with patch("socket.getaddrinfo", return_value=records) as resolve: + nodes = await fetcher( + f"http://entrypoint.test:{server.server_port}/localnodes" + ) + finally: + await fetcher.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert resolve.call_args.args[:2] == ( + "entrypoint.test", + server.server_port, + ) + assert list(nodes) == ["127.0.0.1"] + @pytest.mark.asyncio async def test_dual_stack_dns_all_records_unavailable_fails_clearly( self,