fix(torrent): retry transient downloads and shorten empty-content cooldown

This commit is contained in:
jxxghp
2026-10-10 00:05:22 +08:00
parent 9924e5e7ff
commit e9f0c693e2
4 changed files with 225 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
import datetime
import re
import time
from functools import lru_cache
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union
@@ -24,6 +25,11 @@ from app.schemas.types import MediaType, SystemConfigKey
_SIZE_UNIT = 1024 * 1024
def _is_transient_torrent_status(status: int) -> bool:
"""403 和服务端错误可能来自站点前置代理,不能据此认定种子已失效。"""
return status == 403 or 500 <= status <= 599
class TorrentResponsePort(Protocol):
"""声明种子下载用例实际读取的 HTTP 响应字段。"""
@@ -141,6 +147,30 @@ class TorrentHelper:
"""初始化种子失败地址缓存"""
self._invalid_torrents = TTLCache(region="invalid_torrents", maxsize=128, ttl=3600 * 24)
def _request_torrent_get(self, http_port: TorrentHttpPort, retry: bool,
**request_options: Any) -> Optional[TorrentResponsePort]:
"""普通种子 GET 瞬时失败时退避 1、2 秒;签名换票和确认 POST 不在此重试。"""
for attempt in range(3 if retry else 1):
response = http_port.request(method="GET", **request_options)
if not retry or attempt == 2 or not self._should_retry_torrent_response(response):
return response
logger.warning(f"种子下载遇到瞬时失败,将进行第 {attempt + 1} 次重试")
time.sleep(attempt + 1)
return None
def _should_retry_torrent_response(self, response: Optional[TorrentResponsePort]) -> bool:
"""保留重定向、磁力和首次下载确认页,仅重试网络失败及无效的种子响应。"""
if response is None:
return True
if response.status_code != 200:
return _is_transient_torrent_status(response.status_code)
content = response.content
if content.startswith(b"magnet:"):
return False
if self.get_fileinfo_from_torrent_content(content)[1]:
return False
return "下载种子文件".encode("utf-8") not in content
def download_torrent(self, url: str,
cookie: Optional[str] = None,
ua: Optional[str] = None,
@@ -155,7 +185,7 @@ class TorrentHelper:
:param ua: 请求 User-Agent
:param referer: 请求来源地址
:param proxy: 是否使用系统代理
:param cache_invalid: 是否缓存失败地址;短时凭证地址必须关闭
:param cache_invalid: 是否缓存失败地址并启用普通 GET 重试;短时凭证及换票地址必须关闭
:return: 种子缓存相对路径【用于索引缓存】, 种子内容、种子主目录、种子文件清单、错误信息
"""
if url.startswith("magnet:"):
@@ -181,19 +211,21 @@ class TorrentHelper:
# 下载种子文件
config = get_chain_runtime_config_snapshot()
http_port = _require_torrent_port()
req = http_port.request(
method="GET", url=url, cookie=cookie, ua=ua, referer=referer,
retry = cache_invalid and not torrent_rules.is_expiring_download_url(url)
req = self._request_torrent_get(
http_port, retry, url=url, cookie=cookie, ua=ua, referer=referer,
proxies=config.proxy if proxy else None, allow_redirects=False,
)
while req and req.status_code in [301, 302]:
while req is not None and req.status_code in [301, 302]:
url = req.headers['Location']
if url and url.startswith("magnet:"):
return None, url, "", [], "获取到磁力链接"
req = http_port.request(
method="GET", url=url, cookie=cookie, ua=ua, referer=referer,
req = self._request_torrent_get(
http_port, retry and not torrent_rules.is_expiring_download_url(url),
url=url, cookie=cookie, ua=ua, referer=referer,
proxies=config.proxy if proxy else None, allow_redirects=False,
)
if req and req.status_code == 200:
if req is not None and req.status_code == 200:
if not req.content:
return cache_path, None, "", [], "未下载到种子数据"
# 解析内容格式
@@ -261,7 +293,7 @@ class TorrentHelper:
return cache_path, None, "", [], "触发站点流控,请稍后重试"
else:
# 把错误的种子记下来,避免重复使用
if cache_invalid:
if cache_invalid and not _is_transient_torrent_status(req.status_code):
self.add_invalid(url)
return cache_path, None, "", [], f"下载种子出错,状态码:{req.status_code}"

View File

@@ -26,7 +26,6 @@ DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS = 24 * 60 * 60
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
"无法读取种子文件",
"下载种子内容为空",
"无法获取下载地址",
"种子下载失败",
"torrent not found",
@@ -130,7 +129,7 @@ class DownloadFailureOwner(_DownloadOwnerBase):
@staticmethod
def _download_failure_ttl(error_msg: Optional[str]) -> int:
"""
按失败原因确定资源冷却时间。
按失败原因确定资源冷却时间;泛化的空内容不能证明资源失效,使用瞬时冷却。
"""
error_text = str(error_msg or "").lower()
if any(keyword in error_text for keyword in DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS):

View File

@@ -99,6 +99,14 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)):
这些场景只承诺已验证的 SMB 查询语义;为其他存储接入严格快照时,应在其 SDK/API
边界复现同样的状态转换,并评估目录时间语义、分页和请求成本。
## 种子下载重试回归
种子下载重试的离线边界见 `tests/test_torrent_download_retry.py`:普通 GET 在网络失败、
403/5xx、空响应或无法解析的响应后最多重试两次,退避为 1、2 秒。签名链接刷新、换票地址、
首次下载确认 POST 和 429 流控保持各自的既有流程。测试替换 HTTP 端口与等待,不访问真实站点。
瞬时状态不会写入 24 小时无效链接缓存;订阅记录的泛化“下载种子内容为空”使用 1 小时冷却,
明确资源失效仍保留 24 小时冷却。此策略不追溯修改已经写入的失败记录。
## 自隔离:用了什么,就还原什么
用例若修改了**进程级状态**——`sys.modules` 桩、单例(`Singleton._instances`)、`lru_cache`、环境变量、`settings` 字段——必须在用例或模块结束时还原。pytest 一次性导入全部测试模块,未还原的污染会扩散到后续用例,产生“单独跑过、一起跑挂”的测不准现象。

View File

@@ -0,0 +1,176 @@
"""种子 GET 的有界重试、缓存与冷却策略离线回归。"""
from unittest.mock import Mock
import pytest
from requests import Response
import app.application.torrent.download as download
from app.chain.download.failure import DownloadFailureOwner
URL = "https://tracker.example/download.php?id=39"
CONTENT = b"d4:infod4:name10:Series.mkv6:lengthi1eee"
def _response(status=200, content=CONTENT):
"""使用真实 Response 保证错误状态的布尔值不会掩盖状态码。"""
response = Response()
response.status_code = status
response._content = content
response.encoding = "utf-8"
return response
@pytest.fixture
def boundary(monkeypatch):
"""隔离 HTTP、缓存和等待,保留真实种子解析与用例编排。"""
port = Mock()
monkeypatch.setattr(download, "_require_torrent_port", Mock(return_value=port))
cache = Mock()
cache.get.return_value = None
monkeypatch.setattr(download, "FileCache", Mock(return_value=cache))
sleep = Mock()
monkeypatch.setattr(download.time, "sleep", sleep)
helper = download.TorrentHelper()
helper.add_invalid = Mock()
return helper, port, cache, sleep
@pytest.mark.parametrize("failure", [None, 403, 500, 502, 503, 525, b"", b"d4:info", b"<html>CF</html>"])
def test_transient_failure_recovers_without_invalid_cache(boundary, failure):
"""网络异常的 None、代理错误和截断响应均可恢复,保持访问参数。"""
helper, port, cache, sleep = boundary
failed = _response(failure) if isinstance(failure, int) else (
_response(content=failure) if failure is not None else None
)
port.request.side_effect = [failed, _response()]
result = helper.download_torrent(URL, cookie="session=test", ua="TestAgent", referer="https://tracker.example/")
assert result[1:5] == (CONTENT, "", ["Series.mkv"], "")
assert port.request.call_count == 2
assert port.request.call_args_list[0] == port.request.call_args_list[1]
sleep.assert_called_once_with(1)
helper.add_invalid.assert_not_called()
cache.set.assert_called_once()
@pytest.mark.parametrize("status", [None, 403, 525])
def test_exhausted_retry_is_bounded_and_does_not_poison_url(boundary, status):
"""失败耗尽后保留最终原因,总共三次请求且不写 24 小时缓存。"""
helper, port, cache, sleep = boundary
port.request.return_value = None if status is None else _response(status)
result = helper.download_torrent(URL)
assert result[1] is None
assert result[4] == ("无法打开链接" if status is None else f"下载种子出错,状态码:{status}")
assert port.request.call_count == 3
assert [call.args[0] for call in sleep.call_args_list] == [1, 2]
helper.add_invalid.assert_not_called()
cache.set.assert_not_called()
@pytest.mark.parametrize("status", [401, 404, 410, 429])
def test_permanent_errors_and_rate_limit_are_not_retried(boundary, status):
"""确定失效地址保留无效缓存,429 保留站点流控语义且不短退避。"""
helper, port, _, sleep = boundary
port.request.return_value = _response(status)
assert helper.download_torrent(URL)[1] is None
port.request.assert_called_once()
sleep.assert_not_called()
assert helper.add_invalid.call_count == (0 if status == 429 else 1)
@pytest.mark.parametrize("signed,cache_invalid", [(True, True), (False, False)])
def test_signed_and_indirect_links_keep_single_attempt(boundary, signed, cache_invalid):
"""签名刷新和换票继续由上层持有重试预算。"""
helper, port, _, sleep = boundary
port.request.return_value = _response(403)
url = URL + "&t=1&sign=old" if signed else URL
assert helper.download_torrent(url, cache_invalid=cache_invalid)[1] is None
port.request.assert_called_once()
sleep.assert_not_called()
helper.add_invalid.assert_not_called()
def test_first_download_confirmation_post_is_not_retried(boundary):
"""识别首次下载确认页后只提交一次 POST,不重复有副作用的操作。"""
helper, port, _, sleep = boundary
page = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.encode()
port.request.side_effect = [_response(content=page), _response(503)]
assert helper.download_torrent(URL)[1] is None
assert [call.kwargs["method"] for call in port.request.call_args_list] == ["GET", "POST"]
sleep.assert_not_called()
def test_redirected_get_retries_at_destination(boundary):
"""重定向目标上的瞬时错误也重试,并保留原始 URL 的成功缓存键。"""
helper, port, cache, sleep = boundary
redirect = _response(302)
redirect.headers["Location"] = "https://tracker.example/file.torrent"
port.request.side_effect = [redirect, _response(525), _response()]
assert helper.download_torrent(URL)[1] == CONTENT
assert [call.kwargs["url"] for call in port.request.call_args_list] == [URL, redirect.headers["Location"], redirect.headers["Location"]]
sleep.assert_called_once_with(1)
cache.set.assert_called_once()
def test_second_retry_can_recover(boundary):
"""第二次退避后成功时只缓存最终合法种子。"""
helper, port, cache, sleep = boundary
port.request.side_effect = [None, _response(content=b"d4:info"), _response()]
assert helper.download_torrent(URL)[1] == CONTENT
assert port.request.call_count == 3
assert [call.args[0] for call in sleep.call_args_list] == [1, 2]
cache.set.assert_called_once()
@pytest.mark.parametrize("content", [b"", b"d4:info", b"<html>CF</html>"])
def test_invalid_content_exhaustion_never_caches_response(boundary, content):
"""空或无法解析的响应耗尽预算后仍不能进入种子及无效链接缓存。"""
helper, port, cache, _ = boundary
port.request.return_value = _response(content=content)
assert helper.download_torrent(URL)[1] is None
assert port.request.call_count == 3
cache.set.assert_not_called()
helper.add_invalid.assert_not_called()
@pytest.mark.parametrize("content", [CONTENT, b"magnet:?xt=urn:btih:abc"])
def test_valid_response_has_no_retry(boundary, content):
"""合法种子和磁力响应只发起一次请求。"""
helper, port, _, sleep = boundary
port.request.return_value = _response(content=content)
assert helper.download_torrent(URL)[1]
port.request.assert_called_once()
sleep.assert_not_called()
def test_valid_cache_bypasses_network(boundary):
"""已有合法种子缓存时不发起请求或退避。"""
helper, port, cache, sleep = boundary
cache.get.return_value = CONTENT
assert helper.download_torrent(URL)[1] == CONTENT
port.request.assert_not_called()
sleep.assert_not_called()
@pytest.mark.parametrize("error,ttl", [
("下载种子内容为空", 3600),
("无法打开链接", 3600),
("下载种子出错,状态码:525", 3600),
("无法读取种子文件", 86400),
("torrent not found", 86400),
])
def test_generic_empty_download_is_transient(error, ttl):
"""空内容不证明资源失效;明确文件损坏或删除继续按一天冷却。"""
assert DownloadFailureOwner._download_failure_ttl(error) == ttl