refactor: _fetch_via_http 拆解(94 行 / 圈 21 → 46 行 / 圈 5)

为什么:_fetch_via_http 是全仓内置下载通道的落盘实现,也是本文件此前最大的
圈复杂度所在。94 行的单体里叠着三段互不相关的职责——Range 起点校验、流式写盘
与体积上限、HTTP 错误分类——还共享一个 try/except/finally。上一轮拆
download_music_file 时它是唯一没动的重函数。

改法:拆成 7 个具名成员,主函数只留四步编排(构造 Range 头 → 发请求 →
定追加/重写并校验分片起点 → 流式落盘)。
  _range_headers                  续传时请求「起点之后的全部字节」
  _resolve_write_mode             续传且服务器确实给 206 才追加,否则从头重写
  _verify_content_range           校验 206 分片起点;无法确认就清残片从头下
  _stream_body                    逐块写盘 + 取消检查 + 体积硬上限(500MB)
  _safe_progress                  上报进度,回调异常吞掉(原两处 try/except 收敛)
  _http_status                    从 HTTPError 取状态码
  _is_deterministic_client_error  4xx 判定(408 例外,唯一保留可重试的 4xx)

复杂度(同口径 AST 实测,HEAD 与工作区用同一脚本):
  _fetch_via_http     94 行 / 圈 21 / 嵌套 7  →  46 行 / 圈 5 / 嵌套 3
  文件内最大圈复杂度    21(当时就是它)        →  16(余下最大是 download_music_file)
  文件行数 710 → 753(+43,全是新签名与 docstring)· 函数数 19 → 26
  口径说明:上一轮口头报的「圈 35」用的是另一脚本(另计入布尔运算与比较),
  本轮 HEAD 同口径实测为 21。以本轮为准——HEAD 与重构后由同一脚本测出,可比。

等价性证据(两道,互相独立):
1. 反向注入验证:把 HEAD 版 downloader 注入成 music_downloader.downloader,用同一套
   tests/test_downloader.py 跑 → 30 例**全过**。证明这批测试忠实编码了旧行为,
   因而对新实现构成等价性验证(含本次新增的 5xx 用例,它在旧实现上同样通过)。
2. AST 字面量多重集比对 + 逐条位置定位:数值/布尔 26 种、字符串 80 种,
   差异全部归因,且**没有一条用户可见文案或阈值被改动**:
     206  2→1:旧的两段独立 if(`!= 206` 归零 mode、`== 206` 校验起点)合并进
               _resolve_write_mode 的一个判断
     0   26→24:两处 progress 的 `or 0` 收敛成 expected_size(-1);
               两处 206 判断各含一个 `> 0` 合并成一个 `<= 0`(-1)
     'wb' 2→1:初始赋值与条件改写两处合并成一个 return
   500MB 阈值、各条错误文案原样保留。

重写条件表达式必须补分支穷举(字面量审计不管这一层),已逐路核对:
  resume_from <= 0                     → 'wb'(旧:不进任何 if,保持初始 'wb')
  resume_from > 0 且 status != 206     → 'wb'(旧:第一个 if 命中)
  resume_from > 0 且 status == 206     → 校验起点 + 'ab'(旧:第二个 if 命中)
三路与旧实现一致。

刻意保留的语义(是决定,不是疏漏):
- 超限清理仍**延后到 with 退出后**——Windows 无法删除仍被打开的文件,原实现在
  打开中 unlink 被静默吞掉,残片会留在磁盘上。
- 进度回调异常一律吞掉,绝不影响下载主流程。
- 4xx 里 408 是唯一例外(保留可重试);416 与「206 头被剥离解析不出起点」同口径
  清残片从头下(盲追加拼出的是坏文件)。
- 非 416/4xx 的错误走**裸 raise**(不用 `raise e`),保持原 traceback 不被改动。

测试:新增 1 例 test_5xx_is_retried_and_keeps_part —— 5xx 是服务端瞬时故障而非
确定性失败,须可重试且保留 .part 供续传。这条路径此前无覆盖,且它正是
_is_deterministic_client_error 与 4xx 的分界所在。

顺带记录一个验证工具自身的坑(第一次跑反向注入时 30 例全红,看似「测试不忠实」,
实为注入没生效、测试静默走了真实网络,报错为 Cannot connect to host cdn.test):
只覆盖 sys.modules['music_downloader.downloader'] 不够——pytest 的 monkeypatch
解析目标字符串走 _pytest.monkeypatch.resolve(),是「逐级 getattr 父包」而非纯
sys.modules 查找,包的 downloader 属性仍指向真模块。必须同时 setattr 父包属性。
教训:等价性验证的**工具本身**也要能证伪,否则结论会 180 度反转。

验证:ruff 全过 · pyright 0/0 · pytest 275 passed(274→275)· 行尾 i/lf w/lf
与仓库一致 · 生产 data/ 未被触碰(全部用例走 tmp_path 沙箱)。
This commit is contained in:
snowy-yang committed 2026-09-27 08:05:44 +08:00
1 parent 8716617d5d
commit 9910e4dbec
2 files changed
+123 -60

No files matched your search

+20
View File
@@ -394,6 +394,26 @@ def test_408_is_retried_then_reported_as_failure(env):
assert len(session.calls) == 2, '408 请求超时应保留可重试'
def test_5xx_is_retried_and_keeps_part(env):
"""5xx 是服务端瞬时故障(非确定性失败):应可重试,且保留 .part 供续传。
与 4xx 的分界在这里钉住:error 分类只看 400<=status<500,5xx 落回裸重抛,
由外层 RequestException 分支退避重试。此前这条路径无测试覆盖。
"""
dl, _state, install = env
info = _info(size=100)
fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
session = install(lambda a, u, kw: _FakeResponse([], status=500))
r = dl.download_music_file(101, music_info=info, retries=1)
assert not r.success and '下载请求失败' in r.error_message
assert len(session.calls) == 2, '5xx 非确定性失败,应重试'
assert not fp.exists(), '未交付不得留下最终文件'
assert part.stat().st_size == 10, '.part 应保留供下轮续传'
def test_incomplete_size_keeps_part_and_reports(env):
dl, _state, install = env
info = _info(size=100)