HTTP 返回了 200,磁盘上也出现了文件,并不能证明下载结果已经可以交给后续程序。网络中断、错误响应和直接覆盖旧文件,是三个需要分别处理的问题。
本文用 Python 标准库搭建本地 HTTP 服务,验证一个明确约定:只有下载和校验都通过,最终路径才切换到新文件;失败时保留旧文件。
先定义“成功”是什么
本例下载的是一份已知版本的文件,调用方从可信清单获得预期 SHA-256。程序先检查响应状态和内容编码,再按块写临时文件,核对字节数及摘要,最后替换目标路径。没有可信摘要时,可以检查长度、文件格式、记录数等,但不能把“算出了一个摘要”写成“验证了完整性”。
Content-Length 是接收长度的一项线索,不是业务内容正确的证明。即使服务端返回了一份长度自洽的错误文件,也可能顺利读到 EOF。反过来,没有 Content-Length 也不必直接判失败:本例仍可依据大小上限与预期摘要验收。
本例显式请求 identity 编码,并拒绝其他 Content-Encoding,使响应字节、落盘字节与摘要对应同一层。若要支持压缩响应,应先决定摘要针对压缩包还是解压内容,再调整流程,不能把两种字节混着比。
为什么临时文件要放在目标目录
如果一开始就以 wb 打开最终文件,旧文件已经被截断;后面再报错也无法自动恢复它。本例用唯一临时文件保存候选结果,关闭并校验后才调用 os.replace。Python 文档说明,成功的替换是原子操作,跨文件系统则可能失败;因此临时文件放在目标目录。os.replace 文档
这里保证的是切换时的可见性。它不等于突然断电后必然持久,也没有解决多个下载任务争抢同一路径的问题。代码虽对临时文件执行 fsync,但没有同步父目录;不要据此宣称完成了全面的掉电恢复设计。
完整可运行实验
2026-09-16 在 Python 3.12.14 下运行。仅访问 127.0.0.1,测试数据为本地合成的 75,000 字节,不下载外部网站文件,也不宣称做过大文件性能测试。保存为 download_lab.py,执行 python3 download_lab.py,无第三方依赖。
"""Local-only reproducible download integrity experiment; Python 3.12+."""
import hashlib
import os
import re
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def download(url, destination, expected_sha256, max_bytes=1_000_000):
"""Trusted URL/destination/digest; no resume or concurrent-writer policy."""
if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha256):
raise ValueError("invalid digest")
if max_bytes < 0:
raise ValueError("invalid limit")
destination = Path(destination)
temporary = None
try:
request = Request(url, headers={"Accept-Encoding": "identity"})
with urlopen(request, timeout=5) as response:
if response.status != 200:
raise ValueError("expected status 200")
if response.headers.get("Content-Encoding", "identity").lower() != "identity":
raise ValueError("unexpected content encoding")
raw_length = response.headers.get("Content-Length")
if raw_length is not None and not re.fullmatch(r"[0-9]+", raw_length):
raise ValueError("invalid content length")
length = int(raw_length) if raw_length is not None else None
if length is not None and length > max_bytes:
raise ValueError("declared size exceeds limit")
digest, count = hashlib.sha256(), 0
with tempfile.NamedTemporaryFile(
dir=destination.parent, prefix=".download-", delete=False
) as output:
temporary = Path(output.name)
while chunk := response.read(64 * 1024):
count += len(chunk)
if count > max_bytes:
raise ValueError("actual size exceeds limit")
output.write(chunk)
digest.update(chunk)
if length is not None and count != length:
raise ValueError("length mismatch")
if digest.hexdigest() != expected_sha256.lower():
raise ValueError("digest mismatch")
output.flush()
os.fsync(output.fileno())
os.replace(temporary, destination)
temporary = None
return count
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
PAYLOAD = "可复现的完整文件\n".encode() * 3000
EXPECTED = hashlib.sha256(PAYLOAD).hexdigest()
class Fixture(BaseHTTPRequestHandler):
def do_GET(self):
body = PAYLOAD[:13] if self.path == "/short" else PAYLOAD
status = 503 if self.path == "/error" else 200
self.send_response(status)
if self.path not in ("/unknown", "/oversize"):
self.send_header("Content-Length", str(len(PAYLOAD)))
if self.path == "/encoded":
self.send_header("Content-Encoding", "gzip")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def log_message(self, *args):
pass
def main():
server = ThreadingHTTPServer(("127.0.0.1", 0), Fixture)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{server.server_port}"
cases = [
("complete", "/ok", EXPECTED, 1_000_000, True),
("no_content_length", "/unknown", EXPECTED, 1_000_000, True),
("truncated_body", "/short", EXPECTED, 1_000_000, False),
("wrong_digest", "/ok", "0" * 64, 1_000_000, False),
("http_503", "/error", EXPECTED, 1_000_000, False),
("declared_too_large", "/ok", EXPECTED, 10, False),
("actual_too_large", "/oversize", EXPECTED, 10, False),
("unexpected_encoding", "/encoded", EXPECTED, 1_000_000, False),
]
try:
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / "result.bin"
for name, route, digest, limit, succeeds in cases:
target.write_bytes(b"old-valid-file")
try:
size = download(base + route, target, digest, limit)
except (ValueError, HTTPError):
assert not succeeds, name
assert target.read_bytes() == b"old-valid-file", name
else:
assert succeeds and size == len(PAYLOAD), name
assert target.read_bytes() == PAYLOAD, name
assert not list(Path(directory).glob(".download-*")), name
print("PASS", name)
print(f"{len(cases)} scenarios passed; payload_bytes={len(PAYLOAD)}")
finally:
server.shutdown()
server.server_close()
thread.join()
if __name__ == "__main__":
main()
实际输出与检查含义
PASS complete
PASS no_content_length
PASS truncated_body
PASS wrong_digest
PASS http_503
PASS declared_too_large
PASS actual_too_large
PASS unexpected_encoding
8 scenarios passed; payload_bytes=75000
八个场景包含完整响应、缺少长度头、正文提前结束、错误摘要、HTTP 503、声明超限、实际超限以及不支持的内容编码。每个失败场景都同时检查旧文件仍是原内容,并确认本次临时文件已清理;两个成功场景检查最终文件的全部字节。
这比仅断言“函数抛异常”更接近实际需要:下载失败之后,下游是否还能读到上一个有效版本?
接入采集任务时还要补什么
本函数假定 URL、目标路径和预期摘要都来自可信调用方。它不是面向任意用户 URL 的下载服务,未实现地址访问策略、断点续传、自动重试、并发写入仲裁和崩溃后遗留临时文件回收。
超时参数也不是整个任务的五秒总截止时间。需要总耗时预算时,应在任务层制定截止与取消策略。清理逻辑覆盖正常返回和异常展开,进程被强杀时 finally 不一定执行。
生产环境可以记录来源版本、预期摘要、实际字节数、失败类别和最终提交状态。只有进入提交成功状态,才通知解析器或索引任务消费最终路径;进度条到 100% 只是一个中间信号。
参考资料
urllib.request 响应读取、tempfile 临时文件、hashlib 增量摘要。文中的输入、测试与结果为本地实验,参考文档核对日期为 2026-09-16。

1601

被折叠的 条评论
为什么被折叠?



