utils/dep_freshness/tests/test_http_resolver.py
Krzysztof kuhy Rudnicki 3acf913b32
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
dep_freshness: pool every Maven repo; resolve each host once; retry EAI_AGAIN
Three findings from proving the JitPack fix on the TachiyomiSY gate:

- The first repository with a stable answer used to win. Central still
  carries com.github.ben-manes:gradle-versions-plugin 0.11.1 (2016) while
  the plugin portal has 0.54.0, so a 0.51.0 pin graded "current". Every
  repository is now asked and the newest stable across the pooled lists is
  the answer.
- ~900 lookups, eight at a time, each resolving the registry host (A+AAAA)
  per connection: the LAN router's forwarder started dropping queries for
  every client on the LAN and the gate degraded itself. socket.getaddrinfo
  is memoised per (host, port, hints) for the run, and the per-host probe
  runs under a lock so eight workers make one probe, not eight.
- A resolver EAI_AGAIN ("temporary failure") during the probe marked the
  registry unreachable for the whole run. The probe retries it
  HTTP_ATTEMPTS times; a permanent failure still fails immediately.

Both retry loops lost their unreachable `return` sentinel (http.py is at
100% branch now); the resolver tests live in test_http_resolver.py to keep
test_http.py under the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P4KZw1E2sXJjYqfcZWf1x
2026-09-18 12:08:41 +02:00

118 lines
3.8 KiB
Python

"""The resolver side of the network layer: one resolution and one probe per
host per run, and a *temporary* resolver failure that is retried rather than
declaring the registry unreachable.
Written after the gate's own DNS burst (~900 lookups, eight at a time) made
the LAN router's forwarder drop queries for every client on the LAN
(2026-09-18).
"""
from __future__ import annotations
import io
import socket
import threading
import pytest
from dep_freshness.registries import http
@pytest.fixture(autouse=True)
def real_probe(monkeypatch):
"""Undo conftest's blanket `host_reachable` stub for this module."""
monkeypatch.undo()
http.reset_probes()
http.force_offline(False)
yield
http.reset_probes()
http.force_offline(False)
def test_each_host_is_resolved_once_per_run(monkeypatch):
"""The process-wide resolver is the memoised one, and a repeat (host,
port, hints) key never reaches the system resolver again; `reset_probes`
forgets the addresses along with the reachability verdicts."""
assert socket.getaddrinfo is http.cached_getaddrinfo
asked: list[tuple] = []
def system(host, port, *hints):
asked.append((host, port, *hints))
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.1", port))]
monkeypatch.setattr(http, "_system_getaddrinfo", system)
http.reset_probes()
first = socket.getaddrinfo("repo.example", 443, 0, socket.SOCK_STREAM)
assert socket.getaddrinfo("repo.example", 443, 0, socket.SOCK_STREAM) is first
socket.getaddrinfo("repo.example", 80, 0, socket.SOCK_STREAM)
assert [a[:2] for a in asked] == [("repo.example", 443), ("repo.example", 80)]
http.reset_probes()
socket.getaddrinfo("repo.example", 443, 0, socket.SOCK_STREAM)
assert len(asked) == 3
def test_a_failed_resolution_is_not_remembered(monkeypatch):
def failing(host, port, *hints):
raise socket.gaierror(host)
monkeypatch.setattr(http, "_system_getaddrinfo", failing)
http.reset_probes()
for _ in range(2):
with pytest.raises(socket.gaierror):
socket.getaddrinfo("gone.example", 443)
assert not http._addresses
def test_concurrent_workers_share_one_probe_per_host(monkeypatch):
"""Eight threads asking about the same host at once produce one probe."""
probes: list[str] = []
gate = threading.Barrier(8)
def fake_connect(address, timeout):
probes.append(address[0])
return io.BytesIO()
monkeypatch.setattr(socket, "create_connection", fake_connect)
def worker():
gate.wait()
assert http.host_reachable("https://repo.example/x")
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert probes == ["repo.example"]
def test_a_temporary_resolver_failure_is_retried_but_a_permanent_one_is_not(
monkeypatch,
):
outcomes = [socket.gaierror(socket.EAI_AGAIN, "try again"), io.BytesIO()]
def flaky(address, timeout):
result = outcomes.pop(0)
if isinstance(result, Exception):
raise result
return result
monkeypatch.setattr(socket, "create_connection", flaky)
assert http.host_reachable("https://flaky.example/x")
assert not outcomes
def permanent(address, timeout):
raise socket.gaierror(socket.EAI_NONAME, "no such host")
monkeypatch.setattr(socket, "create_connection", permanent)
assert not http.host_reachable("https://nonesuch.example/x")
always_again = [socket.gaierror(socket.EAI_AGAIN, "again")] * http.HTTP_ATTEMPTS
def exhausted(address, timeout):
raise always_again.pop(0)
monkeypatch.setattr(socket, "create_connection", exhausted)
assert not http.host_reachable("https://never.example/x")
assert not always_again