utils/dep_freshness/registries/http.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

152 lines
5.2 KiB
Python

"""The only module that touches the network.
Three things matter here. First, a single 2s per-host reachability probe
short-circuits every later request, so an offline run fails in seconds instead
of timing out once per package across ~900 lookups. Second, each host is
resolved ONCE per run: `urlopen` asks the resolver (A and AAAA) on every
connection, and ~900 lookups eight at a time against six registry hosts sent
a burst the LAN router's DNS forwarder answered by dropping half of the
queries -- for every client on the LAN, for as long as the gate ran
(2026-09-18). Third, failures are returned, never raised: the gate must
degrade to a cached answer rather than crash a pre-commit hook the user is
forbidden from bypassing.
"""
from __future__ import annotations
import json
import socket
import threading
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from dep_freshness._tables import (
HTTP_ATTEMPTS,
HTTP_TIMEOUT,
PROBE_TIMEOUT,
USER_AGENT,
)
class Offline(Exception):
"""The host is unreachable; callers should fall back to the cache."""
_reachable: dict[str, bool] = {}
# Eight workers start on the same registry at once; without the lock each of
# them resolves and probes the host itself, and that burst alone (16 queries)
# was enough for the router's forwarder to drop some and mark Google Maven
# unreachable for the run.
_probe_lock = threading.Lock()
_forced_offline = False
_addresses: dict[tuple[Any, ...], list[Any]] = {}
_system_getaddrinfo = socket.getaddrinfo
def cached_getaddrinfo(host: Any, port: Any, *args: Any) -> list[Any]:
"""`socket.getaddrinfo`, memoised per (host, port, hints) for the run.
`urllib` offers no resolver hook, so this replaces the socket-level
function for the process. Failures are not memoised: a host that did not
resolve is retried, and `host_reachable` remembers the verdict instead.
"""
key = (host, port, *args)
if key not in _addresses:
_addresses[key] = _system_getaddrinfo(host, port, *args)
return _addresses[key]
socket.getaddrinfo = cached_getaddrinfo
def force_offline(value: bool = True) -> None:
"""Make every request raise `Offline` without touching the network."""
global _forced_offline
_forced_offline = value
def reset_probes() -> None:
"""Forget cached reachability verdicts and addresses (tests, `--refresh`)."""
_reachable.clear()
_addresses.clear()
def host_reachable(url: str) -> bool:
"""One cheap TCP probe per host, memoised for the run."""
if _forced_offline:
return False
parts = urlsplit(url)
host = parts.hostname or ""
port = parts.port or (443 if parts.scheme == "https" else 80)
with _probe_lock:
if host not in _reachable:
_reachable[host] = _probe(host, port)
return _reachable[host]
def _probe(host: str, port: int) -> bool:
"""One TCP connect; a *temporary* resolver failure is retried.
`EAI_AGAIN` is the resolver saying "ask again", not "no such host": a
LAN forwarder that drops a fifth of its queries produces it a few times
per run, and one of them used to mark a registry unreachable for the
whole run and degrade a strict gate to exit 3.
"""
attempt = 0
while True:
attempt += 1
try:
with socket.create_connection((host, port), timeout=PROBE_TIMEOUT):
return True
except socket.gaierror as exc:
if exc.errno != socket.EAI_AGAIN or attempt == HTTP_ATTEMPTS:
return False
except OSError:
return False
def get_text(url: str, accept: str | None = None) -> str | None:
"""Fetch a body as text, or raise `Offline` / return None on 404.
Same contract as `get_json`; Maven metadata is XML, not JSON.
"""
if not host_reachable(url):
raise Offline(url)
headers = {"User-Agent": USER_AGENT}
if accept:
headers["Accept"] = accept
request = Request(url, headers=headers)
# A slow registry is retried with a longer timeout each time; an HTTP
# status is an answer and is never retried.
attempt = 0
while True:
attempt += 1
try:
with urlopen(request, timeout=HTTP_TIMEOUT * attempt) as response:
return response.read().decode("utf-8")
except HTTPError as exc:
if exc.code == 404:
return None
raise Offline(f"{url}: HTTP {exc.code}") from exc
except (URLError, TimeoutError, OSError, ValueError) as exc:
if attempt == HTTP_ATTEMPTS:
raise Offline(f"{url}: {exc}") from exc
def get_json(url: str, accept: str | None = None) -> Any:
"""Fetch and decode JSON, or raise `Offline` / return None on 404.
`Offline` means "ask the cache"; None means "the registry answered and
this package genuinely is not there", which is a real finding rather than
a degraded run and must not be papered over with a stale cache entry.
"""
body = get_text(url, accept)
if body is None:
return None
try:
return json.loads(body)
except ValueError as exc:
raise Offline(f"{url}: {exc}") from exc