mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 20:34:48 +08:00
fix(torrent): retry transient downloads and shorten empty-content cooldown
This commit is contained in:
@@ -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}"
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 一次性导入全部测试模块,未还原的污染会扩散到后续用例,产生“单独跑过、一起跑挂”的测不准现象。
|
||||
|
||||
176
tests/test_torrent_download_retry.py
Normal file
176
tests/test_torrent_download_retry.py
Normal 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
|
||||
Reference in New Issue
Block a user