feat(download): add DoH wireformat support, mirror quarantine, and search warmup (#1218)

- Add RFC 8484 DNS wireformat codec and HTTP/2 support (httpx) for
Quad9/OpenDNS DoH providers.
- Quarantine dead, parked, or seized mirrors for the session on hard
failure (DNS errors, connection refused, 410/451, parked pages) while
preserving bypass clearance on live mirrors.
- Add background startup search warmup to prime DNS, elect mirrors, and
pre-solve protection challenges to eliminate cold-start search latency.
- Add comprehensive test suites for DoH wireformat, mirror quarantine,
parked domain detection, and search warmup.
This commit is contained in:
CaliBrain
2026-08-15 14:17:44 -04:00
committed by GitHub
parent 6e96ead519
commit b656f019be
13 changed files with 1326 additions and 61 deletions
+3
View File
@@ -23,6 +23,9 @@ dependencies = [
"transmission-rpc",
"authlib>=1.7.2,<1.8",
"apprise>=1.12.0",
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
# requests cannot speak. See shelfmark/download/doh_wireformat.py.
"httpx[http2]>=0.27",
]
[project.optional-dependencies]
+158
View File
@@ -0,0 +1,158 @@
"""RFC 8484 DNS wireformat encoding/decoding for DoH providers.
Providers split into two incompatible camps and the difference is not cosmetic:
* **JSON** (Cloudflare, Google) - ``?name=<host>&type=A`` returning a JSON body. A
convention, not a standard, and the only one Shelfmark used to speak.
* **Wireformat** (Quad9, OpenDNS) - RFC 8484 proper: a base64url-encoded DNS message
in ``?dns=``, answered with ``application/dns-message``. Quad9 additionally
*requires HTTP/2* per RFC 8484 section 5.2 and answers HTTP/1.1 with 505.
This module carries the codec only; the transport choice lives in the resolver.
Encoding a query is a handful of bytes, and parsing an answer needs message
compression support (RFC 1035 section 4.1.4) because answer names are almost always
pointers back into the question.
"""
from __future__ import annotations
import base64
import secrets
import struct
# Record types we resolve.
TYPE_A = 1
TYPE_AAAA = 28
_CLASS_IN = 1
_HEADER = struct.Struct(">HHHHHH")
_RR_FIXED = struct.Struct(">HHIH") # type, class, ttl, rdlength
_FLAG_RECURSION_DESIRED = 0x0100
_MAX_LABEL_JUMPS = 64 # cap pointer-following so a malicious answer cannot loop
_MAX_NAME_LENGTH = 255
class WireformatError(ValueError):
"""Raised when a DNS wireformat message cannot be parsed."""
def encode_query(hostname: str, record_type: int) -> bytes:
"""Build a DNS query message for ``hostname``.
The ID is zero because RFC 8484 section 4.1 requires it for cacheability, but the
caller may randomise it when not using a cache.
"""
if not hostname:
msg = "hostname must not be empty"
raise WireformatError(msg)
question = bytearray()
for label in hostname.rstrip(".").split("."):
encoded = label.encode("idna") if not label.isascii() else label.encode("ascii")
if not encoded or len(encoded) > 63:
msg = f"invalid DNS label in {hostname!r}"
raise WireformatError(msg)
question.append(len(encoded))
question.extend(encoded)
question.append(0)
question.extend(struct.pack(">HH", record_type, _CLASS_IN))
header = _HEADER.pack(0, _FLAG_RECURSION_DESIRED, 1, 0, 0, 0)
return header + bytes(question)
def encode_query_param(hostname: str, record_type: int) -> str:
"""Return the base64url ``dns=`` parameter value for a query (padding stripped)."""
return base64.urlsafe_b64encode(encode_query(hostname, record_type)).rstrip(b"=").decode()
def _read_name(message: bytes, offset: int) -> int:
"""Skip over a (possibly compressed) name, returning the offset after it."""
jumps = 0
length = 0
while True:
if offset >= len(message):
msg = "truncated DNS name"
raise WireformatError(msg)
label_len = message[offset]
if label_len == 0:
return offset + 1
if label_len & 0xC0 == 0xC0:
# A pointer ends this name; the rest of the record follows the 2 bytes.
if offset + 1 >= len(message):
msg = "truncated DNS name pointer"
raise WireformatError(msg)
return offset + 2
offset += 1 + label_len
length += 1 + label_len
jumps += 1
if jumps > _MAX_LABEL_JUMPS or length > _MAX_NAME_LENGTH:
msg = "malformed DNS name"
raise WireformatError(msg)
def decode_answer(message: bytes, record_type: int) -> list[str]:
"""Extract the IP addresses of ``record_type`` from a DNS response message.
Returns an empty list for a well-formed response that carries no matching record
(NXDOMAIN, or only CNAMEs), and raises WireformatError for a malformed one - the
caller treats those differently.
"""
if len(message) < _HEADER.size:
msg = "DNS response shorter than its header"
raise WireformatError(msg)
_id, _flags, qdcount, ancount, _ns, _ar = _HEADER.unpack_from(message, 0)
offset = _HEADER.size
for _ in range(qdcount):
offset = _read_name(message, offset)
offset += 4 # QTYPE + QCLASS
results: list[str] = []
for _ in range(ancount):
offset = _read_name(message, offset)
if offset + _RR_FIXED.size > len(message):
msg = "truncated resource record"
raise WireformatError(msg)
rtype, rclass, _ttl, rdlength = _RR_FIXED.unpack_from(message, offset)
offset += _RR_FIXED.size
rdata = message[offset : offset + rdlength]
if len(rdata) != rdlength:
msg = "truncated record data"
raise WireformatError(msg)
offset += rdlength
if rclass != _CLASS_IN or rtype != record_type:
continue
if rtype == TYPE_A and rdlength == 4:
results.append(".".join(str(b) for b in rdata))
elif rtype == TYPE_AAAA and rdlength == 16:
groups = struct.unpack(">8H", rdata)
results.append(_compress_ipv6(groups))
return results
def _compress_ipv6(groups: tuple[int, ...]) -> str:
"""Render an IPv6 address with the longest zero run collapsed to '::'."""
best_start = best_len = -1
run_start = -1
for i, group in enumerate([*list(groups), 1]): # sentinel closes a trailing run
if group == 0 and i < len(groups):
if run_start < 0:
run_start = i
elif run_start >= 0:
if i - run_start > best_len:
best_start, best_len = run_start, i - run_start
run_start = -1
parts = [format(g, "x") for g in groups]
if best_len > 1:
return ":".join(parts[:best_start]) + "::" + ":".join(parts[best_start + best_len :])
return ":".join(parts)
def random_query_id() -> int:
"""A random DNS message ID, for callers that do not want the RFC 8484 zero."""
return secrets.randbelow(0x10000)
+47 -5
View File
@@ -234,13 +234,50 @@ def _is_retryable_error(e: Exception) -> bool:
return status is not None and status in RETRYABLE_CODES
# Statuses that mean the host is gone rather than busy: 410 Gone and 451 Unavailable
# For Legal Reasons are what a seized domain answers with.
_DEAD_MIRROR_CODES = (410, 451)
def _fatal_mirror_reason(e: Exception) -> str | None:
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
Hard evidence only - the name does not resolve, nothing is listening, or the host
says it is gone for good. A timeout, a 5xx or a challenge all mean the mirror is
alive, and rotating off it discards the bypass clearance held for that domain.
"""
status = _get_status_code(e)
if status is not None and status in _DEAD_MIRROR_CODES:
return f"HTTP {status}"
# requests wraps the real cause; a read timeout subclasses ConnectionError for
# some adapters, so exclude timeouts explicitly before inspecting the message.
if isinstance(e, requests.exceptions.Timeout):
return None
if not isinstance(e, requests.exceptions.ConnectionError):
return None
text = str(e).lower()
if "nameresolutionerror" in text or "failed to resolve" in text or "name or service" in text:
return "DNS does not resolve"
if "connection refused" in text or "no route to host" in text:
return "connection refused"
return None
def _try_rotation(
original_url: str, current_url: str, selector: network.AAMirrorSelector
original_url: str,
current_url: str,
selector: network.AAMirrorSelector,
*,
fatal_reason: str | None = None,
) -> str | None:
"""Try mirror/DNS rotation. Returns new URL or None."""
aa_base_url = network.get_aa_base_url()
if aa_base_url and current_url.startswith(aa_base_url):
new_base, action = selector.next_mirror_or_rotate_dns()
new_base, action = selector.next_mirror_or_rotate_dns(
fatal=fatal_reason is not None, reason=fatal_reason or ""
)
if action in ("mirror", "dns") and new_base:
new_url = selector.rewrite(original_url)
logger.info("[%s] switching to: %s", action, new_url)
@@ -552,9 +589,14 @@ def html_get_page(
logger.warning("404 error: %s", current_url)
return _result("", current_url)
# Try mirror/DNS rotation on retryable errors
if _is_retryable_error(e):
new_url = _try_rotation(original_url, current_url, selector)
# Try mirror/DNS rotation on retryable errors. A failure that proves the
# mirror is unusable also drops it from this process's rotation, so the
# next search does not pay for it again.
fatal_reason = _fatal_mirror_reason(e)
if fatal_reason or _is_retryable_error(e):
new_url = _try_rotation(
original_url, current_url, selector, fatal_reason=fatal_reason
)
if new_url:
current_url = new_url
handshake_cookies.clear()
+218 -45
View File
@@ -11,6 +11,7 @@ from socket import AddressFamily, SocketKind
from typing import TYPE_CHECKING, Any, cast
import dns.resolver
import httpx
import requests
from dns.exception import DNSException
@@ -277,6 +278,14 @@ _current_aa_url_index = 0
_aa_urls: list[str] = [] # Initialized lazily in _initialize_aa_state()
_aa_base_url: str = "" # Current active AA URL
# Mirrors quarantined for this process: domains that are not a working AA mirror at
# all (NXDOMAIN, refused, or a 200 that isn't AA - seized/parked/for-sale domains all
# land here). Kept separate from ordinary failures: a 403 challenge or a 5xx means the
# mirror is alive and rotating away from it only discards the DDoS-Guard clearance we
# hold for it. Deliberately in-memory only, so a restart re-probes everything.
_dead_aa_urls: set[str] = set()
_dead_aa_urls_lock = _RLock()
def _ensure_initialized() -> None:
"""Lazy guard so runtime setup happens once and late calls still work."""
@@ -298,6 +307,24 @@ DNS_PROVIDERS = [
("opendns", ["208.67.222.222", "208.67.220.220"], "https://doh.opendns.com/dns-query"),
]
# httpx raises its own hierarchy, which shares no base class with requests', so a
# wireformat failure would escape a requests-only except clause.
_DOH_REQUEST_ERRORS = (OSError, ValueError, requests.RequestException, httpx.HTTPError)
def _first_proxy(proxies: dict[str, str] | None) -> str | None:
"""Pick a single proxy URL from a requests-style mapping, for httpx."""
if not proxies:
return None
return proxies.get("https") or proxies.get("http") or None
# DoH providers that speak RFC 8484 wireformat rather than the (non-standard) JSON API
# Cloudflare and Google popularised. Verified against the live services: both reject a
# ?name=&type= query outright - Quad9 with 505 (it also mandates HTTP/2 per RFC 8484
# section 5.2, which requests cannot speak), OpenDNS with 400 "No valid query received".
_DOH_WIREFORMAT_HOSTS = frozenset({"dns.quad9.net", "doh.opendns.com"})
# Domain patterns that should trigger DNS rotation on failure
DNS_ROTATION_DOMAINS = [
"annas-archive",
@@ -462,8 +489,16 @@ class DoHResolver:
# DNS cache: {(hostname, record_type): (ip_list, timestamp)}
self._cache: dict[tuple[str, str], tuple[list[str], datetime]] = {}
# Different headers based on provider
if "google" in self.base_url:
# RFC 8484 providers get a separate transport: they need wireformat, and Quad9
# additionally refuses HTTP/1.1, which requests has no way to upgrade from.
self.use_wireformat = urllib.parse.urlparse(self.base_url).hostname in (
_DOH_WIREFORMAT_HOSTS
)
self._http2_client: Any | None = None
if self.use_wireformat:
self.session.headers.update({"Accept": "application/dns-message"})
elif "google" in self.base_url:
self.session.headers.update(
{
"Accept": "application/json",
@@ -476,6 +511,35 @@ class DoHResolver:
}
)
def _get_http2_client(self) -> Any:
"""Lazily build the HTTP/2 client used for RFC 8484 providers.
Built on first use so a resolver pointed at a JSON provider never opens an
HTTP/2 connection pool it will not use.
"""
if self._http2_client is None:
self._http2_client = httpx.Client(
http2=True,
timeout=10,
verify=get_ssl_verify(self.base_url),
proxy=_first_proxy(get_proxies(self.base_url)),
)
return self._http2_client
def _resolve_wireformat(self, hostname: str, record_type: str) -> list[str]:
"""Resolve via RFC 8484: base64url query in, DNS message out."""
from shelfmark.download import doh_wireformat
qtype = doh_wireformat.TYPE_AAAA if record_type == "AAAA" else doh_wireformat.TYPE_A
param = doh_wireformat.encode_query_param(hostname, qtype)
response = self._get_http2_client().get(
self.base_url,
params={"dns": param},
headers={"Accept": "application/dns-message"},
)
response.raise_for_status()
return doh_wireformat.decode_answer(response.content, qtype)
def _get_cached(self, hostname: str, record_type: str) -> list[str] | None:
"""Get cached DNS result if still valid."""
key = (hostname, record_type)
@@ -525,34 +589,37 @@ class DoHResolver:
return cached
try:
params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"}
if self.use_wireformat:
answers = self._resolve_wireformat(hostname, record_type)
else:
params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"}
response = self.session.get(
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
timeout=10, # Increased from 5s to handle slow network conditions
verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
response = self.session.get(
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
timeout=10, # Increased from 5s to handle slow network conditions
verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
data = response.json()
if "Answer" not in data:
logger.warning("DoH resolution failed for %s: %s", hostname, data)
return []
data = response.json()
if "Answer" not in data:
logger.warning("DoH resolution failed for %s: %s", hostname, data)
return []
# Extract IP addresses from the response
answers = [
answer["data"]
for answer in data["Answer"]
if answer.get("type") == (28 if record_type == "AAAA" else 1)
]
# Extract IP addresses from the response
answers = [
answer["data"]
for answer in data["Answer"]
if answer.get("type") == (28 if record_type == "AAAA" else 1)
]
# Cache the result
self._set_cached(hostname, record_type, answers)
# Don't log here - the caller (custom_getaddrinfo) will log the final result
except (OSError, ValueError, requests.RequestException) as e:
except _DOH_REQUEST_ERRORS as e:
logger.warning("DoH resolution failed for %s: %s", hostname, e)
return []
else:
@@ -1015,14 +1082,18 @@ def rotate_dns_and_reset_aa() -> bool:
configured_url = _get_configured_aa_url()
if configured_url == "auto":
# Auto mode always resets to the first mirror to restart the cascade
_current_aa_url_index = 0
if _aa_urls:
_aa_base_url = _aa_urls[0]
# Auto mode always resets to the first mirror to restart the cascade. Skip any
# quarantined ones: a new DNS provider cannot revive a parked or seized domain.
with _dead_aa_urls_lock:
restart_urls = [url for url in _aa_urls if url not in _dead_aa_urls] or _aa_urls
if restart_urls:
_aa_base_url = restart_urls[0]
_current_aa_url_index = _aa_urls.index(_aa_base_url)
logger.info("After DNS switch, resetting AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
else:
_aa_base_url = ""
_current_aa_url_index = 0
logger.info("After DNS switch, AA URL remains unconfigured")
else:
# Keep the user's configured primary mirror (if it exists in the list),
@@ -1192,8 +1263,17 @@ def _initialize_aa_state() -> None:
global _aa_base_url, _current_aa_url_index, _aa_urls
# Build URL list from config
previous_urls = _aa_urls
_aa_urls = _build_aa_urls()
# Drop quarantine decisions only when the mirror list itself changed - they were
# made about a list that no longer applies. This runs on every re-init (settings
# sync, DNS rotation, helper subprocess startup), and clearing unconditionally
# would resurrect a parked mirror mid-session.
if previous_urls != _aa_urls:
with _dead_aa_urls_lock:
_dead_aa_urls.clear()
# Get configured base URL from config
configured_url = _get_configured_aa_url()
@@ -1209,26 +1289,34 @@ def _initialize_aa_state() -> None:
return
if configured_url == "auto":
if state.get("aa_base_url") and state["aa_base_url"] in _aa_urls:
_current_aa_url_index = _aa_urls.index(state["aa_base_url"])
_aa_base_url = state["aa_base_url"]
# Never restore or probe a mirror quarantined this session: re-init happens
# often, and re-electing a parked domain costs a wasted request every time
# (its parking page answers 200, so the probe would happily pick it).
with _dead_aa_urls_lock:
candidates = [url for url in _aa_urls if url not in _dead_aa_urls]
restored = state.get("aa_base_url")
if restored and restored in candidates:
_current_aa_url_index = _aa_urls.index(restored)
_aa_base_url = restored
else:
logger.debug("AA_BASE_URL: auto, checking available urls %s", _aa_urls)
for i, url in enumerate(_aa_urls):
logger.debug("AA_BASE_URL: auto, checking available urls %s", candidates)
for url in candidates:
try:
response = requests.get(
url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url)
)
if response.status_code == HTTPStatus.OK:
_current_aa_url_index = i
_current_aa_url_index = _aa_urls.index(url)
_aa_base_url = url
_save_state(aa_url=_aa_base_url)
break
except (OSError, requests.RequestException) as exc:
logger.debug("Could not reach AA mirror candidate %s: %s", url, exc)
if not _aa_base_url or _aa_base_url == "auto":
_aa_base_url = _aa_urls[0]
_current_aa_url_index = 0
# Also covers the case where every probe failed and the previous base is
# itself quarantined - keeping it would aim the next search at a dead host.
if not _aa_base_url or _aa_base_url == "auto" or _aa_base_url not in candidates:
_aa_base_url = (candidates or _aa_urls)[0]
_current_aa_url_index = _aa_urls.index(_aa_base_url)
elif configured_url not in _aa_urls:
logger.info("AA_BASE_URL set to custom value %s; skipping auto-switch", configured_url)
_aa_base_url = configured_url
@@ -1326,24 +1414,77 @@ def is_aa_auto_mode() -> bool:
def get_available_aa_urls() -> list[str]:
"""Get list of configured AA URLs (copy)."""
"""Get configured AA URLs (copy), minus any quarantined this process.
Falls back to the full list when every mirror has been quarantined: a wrong
classification must not leave the app with nowhere to search.
"""
_ensure_initialized()
return _aa_urls.copy()
with _dead_aa_urls_lock:
alive = [url for url in _aa_urls if url not in _dead_aa_urls]
if not alive and _aa_urls:
logger.warning("All AA mirrors quarantined; retrying the full list")
_dead_aa_urls.clear()
return _aa_urls.copy()
return alive
def set_aa_url_index(new_index: int) -> bool:
"""Set AA base URL by index in available list; returns True if applied."""
def _aa_base_for_url(url: str) -> str:
"""Return the configured mirror base that ``url`` belongs to, if any."""
for base in _aa_urls:
if base and url.startswith(base):
return base
return ""
def mark_aa_url_dead(url: str, reason: str) -> bool:
"""Quarantine an AA mirror for the rest of this process.
Only for hard evidence that the host is not a working AA mirror. Transient
failures (403 challenge, 429, 5xx, timeouts) must never come through here -
quarantining a live mirror throws away its bypass clearance.
"""
_ensure_initialized()
base = _aa_base_for_url(url) or url
with _dead_aa_urls_lock:
if base not in _aa_urls or base in _dead_aa_urls:
return False
# Keep at least one mirror in play, even if it is the failing one.
if len([u for u in _aa_urls if u not in _dead_aa_urls]) <= 1:
logger.warning("Not quarantining last remaining AA mirror %s (%s)", base, reason)
return False
_dead_aa_urls.add(base)
logger.warning("Quarantined AA mirror %s for this session: %s", base, reason)
return True
def get_dead_aa_urls() -> set[str]:
"""Return the mirrors quarantined this process (copy)."""
with _dead_aa_urls_lock:
return set(_dead_aa_urls)
def set_aa_url(url: str) -> bool:
"""Set the active AA base URL; returns True if applied."""
_ensure_initialized()
global _aa_base_url, _current_aa_url_index
if new_index < 0 or new_index >= len(_aa_urls):
if url not in _aa_urls:
return False
_current_aa_url_index = new_index
_aa_base_url = _aa_urls[_current_aa_url_index]
_current_aa_url_index = _aa_urls.index(url)
_aa_base_url = url
logger.info("Set AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
return True
def set_aa_url_index(new_index: int) -> bool:
"""Set AA base URL by index in the full configured list; True if applied."""
_ensure_initialized()
if new_index < 0 or new_index >= len(_aa_urls):
return False
return set_aa_url(_aa_urls[new_index])
class AAMirrorSelector:
"""Keep AA mirror switching consistent across call sites.
@@ -1357,6 +1498,10 @@ class AAMirrorSelector:
def _ensure_fresh_state(self, *, reset_attempts: bool = False) -> None:
_ensure_initialized()
self.aa_urls = get_available_aa_urls()
# Rotation walks the live mirrors, but rewriting has to recognise every
# configured base: a URL built before a mirror was quarantined still points at
# it, and failing to rewrite would send the retry back to the dead host.
self.all_aa_urls = _aa_urls.copy()
self._index = self._safe_index(get_aa_base_url())
self.current_base = self.aa_urls[self._index] if self.aa_urls else ""
if reset_attempts:
@@ -1369,16 +1514,41 @@ class AAMirrorSelector:
def rewrite(self, url: str) -> str:
"""Replace any known AA base in url with current_base."""
for base in self.aa_urls:
for base in self.all_aa_urls:
if url.startswith(base):
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, *, allow_dns: bool = True) -> tuple[str | None, str]:
def quarantine_current(self, reason: str) -> bool:
"""Quarantine the mirror this selector is on (hard failures only)."""
if not self.current_base:
return False
dropped = mark_aa_url_dead(self.current_base, reason)
if dropped:
# Rebuild from the surviving mirrors so the dead one is out of the cycle.
self._ensure_fresh_state(reset_attempts=False)
return dropped
def next_mirror_or_rotate_dns(
self, *, allow_dns: bool = True, fatal: bool = False, reason: str = ""
) -> tuple[str | None, str]:
"""Advance to the next mirror or rotate DNS if needed.
``fatal`` marks the current mirror as not-an-AA-mirror (NXDOMAIN, refused, a
200 that isn't AA) and drops it from this process's rotation. Leave it False
for anything the mirror can recover from - a challenge or a 5xx means the host
is alive, and quarantining it would discard its bypass clearance.
Returns (new_base, action) where action is 'mirror', 'dns', or 'exhausted'.
"""
if fatal and self.quarantine_current(reason or "unusable mirror"):
# Quarantining rebuilt the state onto a surviving mirror, so that mirror is
# the next one to try - advancing again here would skip straight past it.
self.attempts_this_dns += 1
if self.current_base and is_aa_auto_mode():
set_aa_url(self.current_base)
return self.current_base, "mirror"
self.attempts_this_dns += 1
max_attempts = len(self.aa_urls) if is_aa_auto_mode() else 1
if self.attempts_this_dns >= max_attempts:
@@ -1391,8 +1561,11 @@ class AAMirrorSelector:
# Mirror is explicitly configured; do not fail over to other mirrors.
return None, "exhausted"
if not self.aa_urls:
return None, "exhausted"
next_index = (self._index + 1) % len(self.aa_urls)
set_aa_url_index(next_index)
set_aa_url(self.aa_urls[next_index])
self._ensure_fresh_state(reset_attempts=False)
return self.current_base, "mirror"
+119
View File
@@ -0,0 +1,119 @@
"""Boot-time warm-up of the direct-download source.
The first AA search after a cold start pays for the whole cold path at once: DNS
resolution, electing a live mirror, spinning up headless Chrome and solving the
DDoS-Guard challenge. That is tens of seconds with the user sat at the search box.
Running one throwaway search shortly after boot moves that cost off the user's first
search. It primes the DNS cache, elects (and quarantines) mirrors, and leaves the
clearance cookie in the bypasser's per-domain cache, so the first real search reuses
it instead of solving from scratch.
Runs on a daemon thread and swallows every failure: this is an optimisation, and a
source that is down at boot must not affect startup or health.
"""
from __future__ import annotations
import threading
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Delay before the warm-up fires. Long enough that it does not compete with the rest
# of startup (and with a container's own health probe) for the first request.
_DEFAULT_DELAY_SECONDS = 15.0
_DEFAULT_QUERY = "The Great Gatsby"
_warmup_thread: threading.Thread | None = None
_warmup_lock = threading.Lock()
def _as_bool(value: object, *, default: bool) -> bool:
"""Coerce a config value that may arrive as a string, bool or None."""
if value is None:
return default
if isinstance(value, str):
from shelfmark.config.env import string_to_bool
return string_to_bool(value)
return bool(value)
def is_enabled() -> bool:
"""Whether the boot-time warm-up search should run."""
if not _as_bool(config.get("SEARCH_WARMUP_ENABLED", True), default=True):
return False
# Nothing to warm if the source is off, and no challenge to pre-solve without
# the bypasser - a plain search is fast enough not to need this.
if not _as_bool(config.get("DIRECT_DOWNLOAD_ENABLED", True), default=True):
logger.debug("Search warm-up skipped: direct download disabled")
return False
return True
def warmup_query() -> str:
"""The query used to warm the source."""
raw = config.get("SEARCH_WARMUP_QUERY", _DEFAULT_QUERY)
query = str(raw).strip() if raw else ""
return query or _DEFAULT_QUERY
def run_warmup() -> bool:
"""Run one warm-up search. Returns True if it produced results.
Never raises: every failure mode here is one the next real search would hit
anyway, and reporting it is the search path's job, not the warm-up's.
"""
from shelfmark.core.mirrors import has_aa_mirror_configuration
if not has_aa_mirror_configuration():
logger.debug("Search warm-up skipped: no Anna's Archive mirrors configured")
return False
query = warmup_query()
logger.info("Warming up direct download search (%r)", query)
try:
from shelfmark.core.models import SearchFilters
from shelfmark.release_sources.direct_download import search_books
results = search_books(query, SearchFilters())
except Exception:
# Broad by design: a warm-up must never take the app down, and the source
# raises everything from network errors to parse failures.
logger.warning("Search warm-up did not complete; first user search may be slow")
logger.debug("Search warm-up failure detail", exc_info=True)
return False
if results:
logger.info("Search warm-up complete: %s results, source is ready", len(results))
return True
logger.info("Search warm-up returned no results; source reachable but empty")
return False
def start(delay_seconds: float = _DEFAULT_DELAY_SECONDS) -> bool:
"""Schedule the warm-up on a daemon thread. Safe to call multiple times."""
global _warmup_thread
if not is_enabled():
return False
with _warmup_lock:
if _warmup_thread is not None and _warmup_thread.is_alive():
logger.debug("Search warm-up already scheduled")
return False
def _run() -> None:
run_warmup()
_warmup_thread = threading.Timer(delay_seconds, _run)
_warmup_thread.daemon = True
_warmup_thread.name = "SearchWarmup"
_warmup_thread.start()
logger.debug("Search warm-up scheduled in %ss", delay_seconds)
return True
+5
View File
@@ -85,6 +85,7 @@ from shelfmark.core.requests_service import (
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
from shelfmark.download import orchestrator as backend
from shelfmark.download import warmup
from shelfmark.release_sources import (
BrowseRecord,
Release,
@@ -206,6 +207,10 @@ except (sqlite3.OperationalError, OSError) as e:
# Start download coordinator
backend.start()
# Pre-solve the direct-download source's protection challenge in the background so the
# first user search does not pay for a cold Chrome bypass. Never blocks startup.
warmup.start()
# Rate limiting for login attempts
# Map usernames to their failed-attempt counters and lockout timestamps.
failed_login_attempts: dict[str, dict[str, Any]] = {}
+74 -9
View File
@@ -539,6 +539,79 @@ class SearchUnavailableError(SourceUnavailableError):
"""Raised when Anna's Archive cannot be reached via any mirror/DNS."""
# Markers that prove a 200 really came from Anna's Archive, and markers that mean we
# are looking at a protection interstitial rather than the site. A page with neither
# is a domain that answers but is not AA - seized, parked or for sale.
#
# Deliberately structural rather than the domain name: a parking page's whole job is
# to display the domain it is squatting on, so "annas-archive" matches the very pages
# this is meant to catch. These paths only exist on the real site.
_AA_PAGE_MARKERS = (
"/md5/",
"aarecord",
"anna's archive",
"/dyn/",
"/datasets",
"/fast_download",
"/slow_download",
)
_CHALLENGE_MARKERS = (
"ddos-guard",
"just a moment",
"cloudflare",
"checking your browser",
"cf-browser-verification",
)
def _looks_like_aa_page(html: str) -> bool:
"""Whether ``html`` is recognisably Anna's Archive, or a challenge in front of it."""
lowered = html.lower()
return any(marker in lowered for marker in (*_AA_PAGE_MARKERS, *_CHALLENGE_MARKERS))
def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[str, Tag | None]:
"""Fetch the AA search page, retrying past mirrors that are not actually AA.
A parked or seized domain answers 200 with a page that has no results table and no
"No files found." - indistinguishable from a broken search unless we check whether
the response looks like AA at all. Those mirrors are quarantined for the session so
later searches skip them instead of paying the timeout again.
"""
attempt_url = url
for _ in range(len(network.get_available_aa_urls()) or 1):
response = downloader.html_get_page(
attempt_url, selector=selector, allow_bypasser_fallback=True
)
if not response:
# Network/mirror exhaustion path bubbles up so API can notify clients
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
raise SearchUnavailableError(msg)
html = _html_response_text(response)
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
if isinstance(table, Tag):
return html, table
if table is not None:
msg = f"Expected results table tag, got {type(table).__name__}"
raise TypeError(msg)
if "No files found." in html or _looks_like_aa_page(html):
# A real AA response - either genuinely empty, or a shape the caller
# should report as drift. Not the mirror's fault.
return html, None
new_base, action = selector.next_mirror_or_rotate_dns(
fatal=True, reason="responded without an Anna's Archive page"
)
if action not in ("mirror", "dns") or not new_base:
return html, None
attempt_url = selector.rewrite(url)
logger.info("Retrying search on %s", new_base)
return "", None
def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
"""Search for books matching the query.
@@ -603,15 +676,7 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
# AA gates /search behind a DDoS-Guard JS challenge, which every mirror shares. Rotating
# to another mirror only collects another 403, so let the bypasser solve it.
html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=True)
if not html:
# Network/mirror exhaustion path bubbles up so API can notify clients
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
raise SearchUnavailableError(msg)
soup = BeautifulSoup(_html_response_text(html), "html.parser")
tbody = soup.find("table")
html, tbody = _fetch_search_table(url, selector)
if tbody is None:
if "No files found." in html:
logger.info("No books found for query: %s", query)
@@ -0,0 +1,107 @@
"""A mirror that answers 200 with a non-AA page is quarantined, not reported as empty.
Seized and for-sale domains keep serving 200. Without a look at *what* came back, a
parking page is indistinguishable from a broken search, so the mirror stays in
rotation and every later search pays for it again.
"""
from bs4 import Tag
PARKED_PAGE = """<!doctype html><html><head><title>annas-archive.li</title></head>
<body><h1>This domain is for sale</h1><p>Inquire now. Buy this domain.</p></body></html>"""
AA_RESULTS_PAGE = """<!doctype html><html><body><main><table><tbody>
<tr><td><a href="/md5/abc123"><img src="/c.jpg"></a></td><td><span>Dune</span></td></tr>
</tbody></table></main></body></html>"""
AA_EMPTY_PAGE = """<!doctype html><html><body><main>
<div>No files found.</div><a href="https://annas-archive.gl/about">about</a>
</main></body></html>"""
DDOS_GUARD_PAGE = """<!doctype html><html><head><title>DDoS-Guard</title></head>
<body><div id="ddos-guard">Checking your browser</div></body></html>"""
class _Selector:
def __init__(self, bases: list[str]) -> None:
self._bases = bases
self._index = 0
self.current_base = bases[0]
self.quarantined: list[str] = []
def rewrite(self, url: str) -> str:
for base in self._bases:
if url.startswith(base):
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
if fatal:
self.quarantined.append(self.current_base)
self._index += 1
if self._index >= len(self._bases):
return None, "exhausted"
self.current_base = self._bases[self._index]
return self.current_base, "mirror"
def _patch_pages(monkeypatch, pages: list[str]):
"""Serve `pages` in order, recording the URL each call was made against."""
import shelfmark.release_sources.direct_download as dd
calls: list[str] = []
def fake_get(url, **_kwargs):
calls.append(url)
return pages[len(calls) - 1] if len(calls) <= len(pages) else ""
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a", "b", "c"])
return dd, calls
def test_parked_mirror_is_quarantined_and_search_retries_next_mirror(monkeypatch):
dd, calls = _patch_pages(monkeypatch, [PARKED_PAGE, AA_RESULTS_PAGE])
selector = _Selector(["https://parked.test", "https://real.test"])
html, table = dd._fetch_search_table("https://parked.test/search?q=dune", selector)
assert selector.quarantined == ["https://parked.test"]
assert isinstance(table, Tag)
assert "Dune" in html
# The retry went to the live mirror, not back to the parked one.
assert calls[1].startswith("https://real.test")
def test_genuinely_empty_aa_result_does_not_quarantine(monkeypatch):
"""'No files found.' is a real answer from a healthy mirror."""
dd, _calls = _patch_pages(monkeypatch, [AA_EMPTY_PAGE])
selector = _Selector(["https://real.test", "https://other.test"])
html, table = dd._fetch_search_table("https://real.test/search?q=zzz", selector)
assert selector.quarantined == []
assert table is None
assert "No files found." in html
def test_challenge_page_does_not_quarantine(monkeypatch):
"""A DDoS-Guard interstitial means the mirror is alive and holds our clearance."""
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_PAGE])
selector = _Selector(["https://real.test", "https://other.test"])
_html, table = dd._fetch_search_table("https://real.test/search?q=dune", selector)
assert selector.quarantined == []
assert table is None
def test_unreachable_mirror_raises_search_unavailable(monkeypatch):
dd, _calls = _patch_pages(monkeypatch, [""])
selector = _Selector(["https://real.test"])
try:
dd._fetch_search_table("https://real.test/search?q=dune", selector)
except dd.SearchUnavailableError:
return
raise AssertionError("expected SearchUnavailableError")
+143
View File
@@ -0,0 +1,143 @@
"""RFC 8484 wireformat codec.
Quad9 and OpenDNS reject the JSON API that Cloudflare and Google popularised, so
these providers only work through wireformat. Quad9 additionally requires HTTP/2
(section 5.2) and answers HTTP/1.1 with 505.
"""
import base64
import struct
import pytest
from shelfmark.download import doh_wireformat as wf
def _decode_param(param: str) -> bytes:
padding = "=" * (-len(param) % 4)
return base64.urlsafe_b64decode(param + padding)
def _build_response(
*, qname: str = "example.com", answers: list[tuple[int, bytes]], qtype: int = wf.TYPE_A
) -> bytes:
"""Assemble a response whose answer names are compression pointers to the question."""
question = b""
for label in qname.split("."):
question += bytes([len(label)]) + label.encode()
question += b"\x00" + struct.pack(">HH", qtype, 1)
body = b""
for rtype, rdata in answers:
body += b"\xc0\x0c" # pointer to offset 12 (the question name)
body += struct.pack(">HHIH", rtype, 1, 300, len(rdata)) + rdata
header = struct.pack(">HHHHHH", 0, 0x8180, 1, len(answers), 0, 0)
return header + question + body
def test_encode_query_is_a_well_formed_dns_message():
raw = _decode_param(wf.encode_query_param("example.com", wf.TYPE_A))
msg_id, flags, qdcount, ancount, _ns, _ar = struct.unpack_from(">HHHHHH", raw, 0)
assert msg_id == 0 # RFC 8484 section 4.1: zero for cacheability
assert flags == 0x0100 # recursion desired
assert (qdcount, ancount) == (1, 0)
assert raw[12:] == b"\x07example\x03com\x00" + struct.pack(">HH", wf.TYPE_A, 1)
def test_encode_query_param_is_unpadded_base64url():
param = wf.encode_query_param("example.com", wf.TYPE_A)
assert "=" not in param
assert "+" not in param and "/" not in param
def test_encode_query_strips_trailing_dot():
assert _decode_param(wf.encode_query_param("example.com.", wf.TYPE_A)) == _decode_param(
wf.encode_query_param("example.com", wf.TYPE_A)
)
def test_encode_query_rejects_empty_hostname():
with pytest.raises(wf.WireformatError):
wf.encode_query("", wf.TYPE_A)
def test_encode_query_rejects_oversized_label():
with pytest.raises(wf.WireformatError):
wf.encode_query("a" * 64 + ".com", wf.TYPE_A)
def test_decode_a_records():
response = _build_response(answers=[(wf.TYPE_A, bytes([93, 184, 216, 34]))])
assert wf.decode_answer(response, wf.TYPE_A) == ["93.184.216.34"]
def test_decode_multiple_a_records_preserves_order():
response = _build_response(
answers=[(wf.TYPE_A, bytes([1, 1, 1, 1])), (wf.TYPE_A, bytes([8, 8, 8, 8]))]
)
assert wf.decode_answer(response, wf.TYPE_A) == ["1.1.1.1", "8.8.8.8"]
def test_decode_skips_cname_records_in_the_chain():
"""Answers routinely lead with a CNAME; only the requested type is an address."""
cname = b"\x03www\x07example\x03com\x00"
response = _build_response(answers=[(5, cname), (wf.TYPE_A, bytes([93, 184, 216, 34]))])
assert wf.decode_answer(response, wf.TYPE_A) == ["93.184.216.34"]
def test_decode_aaaa_compresses_zero_run():
# 2606:4700:0:0:0:0:6810:84e5 -> the middle zero run collapses to "::"
rdata = struct.pack(">8H", 0x2606, 0x4700, 0, 0, 0, 0, 0x6810, 0x84E5)
response = _build_response(answers=[(wf.TYPE_AAAA, rdata)], qtype=wf.TYPE_AAAA)
assert wf.decode_answer(response, wf.TYPE_AAAA) == ["2606:4700::6810:84e5"]
def test_decode_aaaa_collapses_only_the_longest_zero_run():
# 2001:0:0:1:0:0:0:1 - the second, longer run is the one that collapses.
rdata = struct.pack(">8H", 0x2001, 0, 0, 1, 0, 0, 0, 1)
response = _build_response(answers=[(wf.TYPE_AAAA, rdata)], qtype=wf.TYPE_AAAA)
assert wf.decode_answer(response, wf.TYPE_AAAA) == ["2001:0:0:1::1"]
def test_decode_aaaa_without_zero_run():
rdata = struct.pack(">8H", 0x2001, 0x0DB8, 1, 2, 3, 4, 5, 6)
response = _build_response(answers=[(wf.TYPE_AAAA, rdata)], qtype=wf.TYPE_AAAA)
assert wf.decode_answer(response, wf.TYPE_AAAA) == ["2001:db8:1:2:3:4:5:6"]
def test_decode_nxdomain_returns_empty_not_an_error():
"""An empty answer is a valid response; the caller falls back rather than retrying."""
response = _build_response(answers=[])
assert wf.decode_answer(response, wf.TYPE_A) == []
def test_decode_rejects_truncated_header():
with pytest.raises(wf.WireformatError):
wf.decode_answer(b"\x00\x01", wf.TYPE_A)
def test_decode_rejects_truncated_record():
response = _build_response(answers=[(wf.TYPE_A, bytes([1, 2, 3, 4]))])
with pytest.raises(wf.WireformatError):
wf.decode_answer(response[:-2], wf.TYPE_A)
def test_decode_does_not_hang_on_a_malicious_name():
"""A self-referential name must not loop forever."""
header = struct.pack(">HHHHHH", 0, 0x8180, 1, 0, 0, 0)
# A run of maximum-length labels that never terminates.
body = (b"\x3f" + b"a" * 63) * 8
with pytest.raises(wf.WireformatError):
wf.decode_answer(header + body, wf.TYPE_A)
def test_wireformat_providers_are_flagged(monkeypatch):
"""The provider table and the resolver must agree on who needs wireformat."""
import shelfmark.download.network as network
for name, servers, url in network.DNS_PROVIDERS:
resolver = network.DoHResolver(url, "x.invalid", servers[0])
expected = name in ("quad9", "opendns")
assert resolver.use_wireformat is expected, f"{name} wireformat flag wrong"
+6 -1
View File
@@ -27,6 +27,7 @@ class _DummySelector:
self._index = 0
self.current_base = bases[0]
self.attempts_this_dns = 0
self.quarantined: list[tuple[str, str]] = []
def rewrite(self, url: str) -> str:
for base in self._bases:
@@ -34,7 +35,11 @@ class _DummySelector:
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, allow_dns: bool = True) -> tuple[str | None, str]:
def next_mirror_or_rotate_dns(
self, allow_dns: bool = True, *, fatal: bool = False, reason: str = ""
) -> tuple[str | None, str]:
if fatal:
self.quarantined.append((self.current_base, reason))
self.attempts_this_dns += 1
self._index = (self._index + 1) % len(self._bases)
self.current_base = self._bases[self._index]
@@ -0,0 +1,221 @@
"""Tests for AA mirror quarantine: dead mirrors leave the rotation, live ones stay.
The distinction these guard is the whole point of the feature. A mirror that answers
403 (DDoS-Guard) is alive and holds our bypass clearance, so rotating off it makes the
next search solve a fresh challenge on a domain we have no cookie for. A mirror that
NXDOMAINs, refuses the connection, or answers 200 with a parking page is not a mirror
at all and must never be tried again this session.
"""
import requests
def _fresh_network(monkeypatch, urls: list[str], *, auto: bool = True):
import shelfmark.download.network as network
monkeypatch.setattr(network, "_initialized", True)
monkeypatch.setattr(network, "_aa_urls", list(urls))
monkeypatch.setattr(network, "_aa_base_url", urls[0])
monkeypatch.setattr(network, "_current_aa_url_index", 0)
monkeypatch.setattr(network, "_dead_aa_urls", set())
monkeypatch.setattr(network, "_save_state", lambda **kwargs: None)
monkeypatch.setattr(network, "is_aa_auto_mode", lambda: auto)
return network
MIRRORS = ["https://aa-one.test", "https://aa-two.test", "https://aa-three.test"]
def test_quarantined_mirror_leaves_the_available_list(monkeypatch):
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is True
assert network.get_available_aa_urls() == ["https://aa-one.test", "https://aa-three.test"]
assert network.get_dead_aa_urls() == {"https://aa-two.test"}
def test_quarantine_accepts_a_full_request_url(monkeypatch):
"""Callers hold the failing request URL, not the bare mirror base."""
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-two.test/search?q=dune", "parked") is True
assert "https://aa-two.test" in network.get_dead_aa_urls()
def test_quarantine_is_idempotent(monkeypatch):
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is True
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is False
assert network.get_available_aa_urls() == ["https://aa-one.test", "https://aa-three.test"]
def test_last_surviving_mirror_is_never_quarantined(monkeypatch):
"""Misclassification must not leave the app with nowhere to search."""
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-one.test", "NXDOMAIN") is True
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is True
assert network.mark_aa_url_dead("https://aa-three.test", "NXDOMAIN") is False
assert network.get_available_aa_urls() == ["https://aa-three.test"]
def test_selector_skips_quarantined_mirror_when_rotating(monkeypatch):
network = _fresh_network(monkeypatch, MIRRORS)
selector = network.AAMirrorSelector()
new_base, action = selector.next_mirror_or_rotate_dns(fatal=True, reason="NXDOMAIN")
assert action == "mirror"
# Landed on the next live mirror, not skipped past it onto the third.
assert new_base == "https://aa-two.test"
assert "https://aa-one.test" in network.get_dead_aa_urls()
assert selector.rewrite("https://aa-one.test/search") == "https://aa-two.test/search"
def test_non_fatal_rotation_keeps_the_mirror(monkeypatch):
"""A 5xx or a challenge rotates but must not burn the mirror."""
network = _fresh_network(monkeypatch, MIRRORS)
selector = network.AAMirrorSelector()
selector.next_mirror_or_rotate_dns()
assert network.get_dead_aa_urls() == set()
assert network.get_available_aa_urls() == MIRRORS
def test_dns_reset_does_not_resurrect_quarantined_mirrors(monkeypatch):
"""A new DNS provider cannot revive a parked domain, so it stays skipped."""
network = _fresh_network(monkeypatch, MIRRORS)
monkeypatch.setattr(network, "rotate_dns_provider", lambda: True)
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
network.mark_aa_url_dead("https://aa-one.test", "parked")
assert network.rotate_dns_and_reset_aa() is True
assert network.get_aa_base_url() == "https://aa-two.test"
def test_editing_the_mirror_list_clears_quarantine(monkeypatch):
"""Quarantine decisions were made about a list the user has now changed."""
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-two.test", "parked")
monkeypatch.setattr(network, "_build_aa_urls", lambda: [*MIRRORS, "https://aa-four.test"])
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {"aa_base_url": "https://aa-one.test"})
network._initialize_aa_state()
assert network.get_dead_aa_urls() == set()
def test_reinit_with_an_unchanged_list_keeps_quarantine(monkeypatch):
"""Re-init happens constantly (settings sync, DNS rotation, helper startup).
Clearing quarantine on every one of those resurrects a parked mirror mid-session,
which is exactly the bug this guards: the mirror gets re-elected and the next
search pays for it again.
"""
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-two.test", "parked")
monkeypatch.setattr(network, "_build_aa_urls", lambda: list(MIRRORS))
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {"aa_base_url": "https://aa-one.test"})
network._initialize_aa_state()
assert network.get_dead_aa_urls() == {"https://aa-two.test"}
# --------------------------------------------------------------------------- #
# Failure classification
# --------------------------------------------------------------------------- #
def _http_error(status: int) -> requests.exceptions.HTTPError:
response = requests.Response()
response.status_code = status
return requests.exceptions.HTTPError(response=response)
def test_dns_failure_is_fatal_for_the_mirror():
import shelfmark.download.http as http
exc = requests.exceptions.ConnectionError(
"HTTPSConnectionPool(host='aa.test', port=443): Max retries exceeded "
"(Caused by NameResolutionError(\"Failed to resolve 'aa.test'\"))"
)
assert http._fatal_mirror_reason(exc) == "DNS does not resolve"
def test_connection_refused_is_fatal_for_the_mirror():
import shelfmark.download.http as http
exc = requests.exceptions.ConnectionError("Connection refused")
assert http._fatal_mirror_reason(exc) == "connection refused"
def test_gone_and_legal_block_are_fatal():
import shelfmark.download.http as http
assert http._fatal_mirror_reason(_http_error(410)) == "HTTP 410"
assert http._fatal_mirror_reason(_http_error(451)) == "HTTP 451"
def test_timeout_is_not_fatal():
"""A slow mirror is still a mirror - and may hold our bypass clearance."""
import shelfmark.download.http as http
assert http._fatal_mirror_reason(requests.exceptions.ConnectTimeout("timed out")) is None
assert http._fatal_mirror_reason(requests.exceptions.ReadTimeout("timed out")) is None
def test_challenge_and_server_errors_are_not_fatal():
import shelfmark.download.http as http
for status in (403, 429, 500, 502, 503):
assert http._fatal_mirror_reason(_http_error(status)) is None
def test_startup_probe_skips_quarantined_mirrors(monkeypatch):
"""Re-init must not re-probe (or re-elect) a mirror already known to be dead.
A parking page answers 200, so an unfiltered probe elects it every single time
the app re-initialises - one wasted request per re-init, forever.
"""
import requests
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-one.test", "parked")
probed: list[str] = []
def fake_get(url, **_kwargs):
probed.append(url)
response = requests.Response()
response.status_code = 200
return response
monkeypatch.setattr(network.requests, "get", fake_get)
monkeypatch.setattr(network, "_build_aa_urls", lambda: list(MIRRORS))
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {})
monkeypatch.setattr(network, "get_proxies", lambda _url: None)
monkeypatch.setattr(network, "get_ssl_verify", lambda _url: True)
network._initialize_aa_state()
assert "https://aa-one.test" not in probed
assert network.get_aa_base_url() == "https://aa-two.test"
def test_startup_probe_does_not_restore_a_quarantined_mirror(monkeypatch):
"""Saved state can name a mirror that has since been quarantined."""
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-one.test", "parked")
monkeypatch.setattr(network, "_build_aa_urls", lambda: list(MIRRORS))
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {"aa_base_url": "https://aa-one.test"})
monkeypatch.setattr(network, "get_proxies", lambda _url: None)
monkeypatch.setattr(network, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(network.requests, "get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
network._initialize_aa_state()
assert network.get_aa_base_url() != "https://aa-one.test"
+146
View File
@@ -0,0 +1,146 @@
"""Boot-time search warm-up.
The warm-up exists to move the cold DDoS-Guard solve off the user's first search. It
is an optimisation, so the load-bearing property is that it can never affect startup:
a source that is down, misconfigured or raising must leave the app running.
"""
import pytest
@pytest.fixture
def warmup(monkeypatch):
import shelfmark.download.warmup as warmup_module
monkeypatch.setattr(warmup_module, "_warmup_thread", None)
return warmup_module
def _patch_config(monkeypatch, warmup, values: dict):
def fake_get(key, default=None):
return values.get(key, default)
monkeypatch.setattr(warmup.config, "get", fake_get)
def test_disabled_by_setting(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_ENABLED": False})
assert warmup.is_enabled() is False
assert warmup.start() is False
def test_disabled_by_string_false(monkeypatch, warmup):
"""Deployment ENV arrives as a string, not a bool."""
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_ENABLED": "false"})
assert warmup.is_enabled() is False
def test_skipped_when_direct_download_is_off(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {"DIRECT_DOWNLOAD_ENABLED": False})
assert warmup.is_enabled() is False
def test_enabled_by_default(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
assert warmup.is_enabled() is True
def test_query_defaults_and_is_configurable(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
assert warmup.warmup_query() == "The Great Gatsby"
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_QUERY": "Dune"})
assert warmup.warmup_query() == "Dune"
# A blank override must not send an empty query at the source.
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_QUERY": " "})
assert warmup.warmup_query() == "The Great Gatsby"
def test_skipped_when_no_mirrors_configured(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: False)
called: list[str] = []
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd, "search_books", lambda q, f: called.append(q))
assert warmup.run_warmup() is False
assert called == []
def test_successful_warmup_reports_true(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: True)
seen: list[str] = []
def fake_search(query, _filters):
seen.append(query)
return ["a", "b"]
monkeypatch.setattr(dd, "search_books", fake_search)
assert warmup.run_warmup() is True
assert seen == ["The Great Gatsby"]
def test_empty_results_are_not_an_error(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: True)
monkeypatch.setattr(dd, "search_books", lambda q, f: [])
assert warmup.run_warmup() is False
def test_search_failure_is_swallowed(monkeypatch, warmup):
"""A source that is down at boot must not propagate out of the warm-up."""
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: True)
def boom(_query, _filters):
msg = "mirrors are blocked"
raise RuntimeError(msg)
monkeypatch.setattr(dd, "search_books", boom)
assert warmup.run_warmup() is False
def test_start_schedules_a_daemon_thread_and_is_idempotent(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
assert warmup.start(delay_seconds=30) is True
thread = warmup._warmup_thread
assert thread is not None
assert thread.daemon is True
# A second call must not stack up another timer.
assert warmup.start(delay_seconds=30) is False
assert warmup._warmup_thread is thread
thread.cancel()
def test_start_does_not_run_the_search_inline(monkeypatch, warmup):
"""Startup must not block on a search that can take a minute."""
_patch_config(monkeypatch, warmup, {})
ran: list[bool] = []
monkeypatch.setattr(warmup, "run_warmup", lambda: ran.append(True))
warmup.start(delay_seconds=30)
assert ran == []
if warmup._warmup_thread:
warmup._warmup_thread.cancel()
Generated
+79 -1
View File
@@ -1,7 +1,19 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.14"
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "apprise"
version = "1.12.0"
@@ -546,6 +558,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.18"
@@ -1391,6 +1467,7 @@ dependencies = [
{ name = "gevent" },
{ name = "gevent-websocket" },
{ name = "gunicorn" },
{ name = "httpx", extra = ["http2"] },
{ name = "psutil" },
{ name = "python-socketio" },
{ name = "qbittorrent-api" },
@@ -1433,6 +1510,7 @@ requires-dist = [
{ name = "gevent" },
{ name = "gevent-websocket" },
{ name = "gunicorn" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.27" },
{ name = "psutil" },
{ name = "pyautogui", marker = "extra == 'browser'" },
{ name = "python-socketio" },