HTTP Range 下载验证:先核对区间,再拼接字节
断点下载最危险的错误,往往不是请求失败,而是把完整响应当成剩余片段接到旧文件后面。Range 是请求意图,下载器还要证明响应满足这次意图。本文依据 RFC 9110,只讨论单个字节区间的验收;示例已在 CPython 3.12.14 本地执行,不请求外部站点,也不写入真实下载文件。
AI生成概念配图:一个字节序列被分段取出,经过状态、区间和长度三道检查后才允许拼接
先把区间算清楚
字节偏移从零开始,结束位置包含在范围内。bytes=4-7 请求四个字节,而不是三个;在十六字节资源里,对应内容 4567。bytes=4- 表示从偏移四到末尾,bytes=-4 表示最后四字节。不要把这些写法混用,更不要把字符数当作字节数,中文文本的字符位置与编码后的字节偏移通常不同。
服务器可以忽略 Range,因此发出区间请求仍可能得到 200。接受单区间 206 时,应核对 Content-Range 中的起止位置、总长度及实际收到的字节数;416 表示范围无法满足,响应可以提供当前总长度。Accept-Ranges 只是能力提示,既不能替代结果验证,也不是客户端发出区间请求的必要前提。
用只监听本机的夹具观察三个分支
把以下程序保存为 range_demo.py 后运行 python3 range_demo.py。它只实现三个预设响应,其他请求返回 400,目的是测试客户端分支,不是可上线的文件服务器。临时端口由系统分配,finally 会关闭服务;客户端设置超时,并把本例读取上限限制为十七字节。
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.client import HTTPConnection
from threading import Thread
DATA = b'0123456789abcdef'
class Fixture(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
requested = self.headers.get('Range')
if self.path == '/ignore':
status, content_range, body = 200, None, DATA
elif requested == 'bytes=4-7':
status, content_range, body = 206, 'bytes 4-7/16', DATA[4:8]
elif requested == 'bytes=99-':
status, content_range, body = 416, 'bytes */16', b''
else:
status, content_range, body = 400, None, b''
self.send_response(status)
self.send_header('Content-Length', str(len(body)))
if content_range:
self.send_header('Content-Range', content_range)
self.end_headers()
self.wfile.write(body)
server = HTTPServer(('127.0.0.1', 0), Fixture)
worker = Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
for path, range_value in [('/asset', 'bytes=4-7'),
('/ignore', 'bytes=4-7'),
('/asset', 'bytes=99-')]:
conn = HTTPConnection('127.0.0.1', server.server_port, timeout=3)
try:
conn.request('GET', path, headers={
'Range': range_value, 'Accept-Encoding': 'identity'})
response = conn.getresponse()
body = response.read(17)
accepted = (range_value == 'bytes=4-7'
and response.status == 206
and response.getheader('Content-Range') == 'bytes 4-7/16'
and response.getheader('Content-Encoding') is None
and len(body) == 4)
print(response.status, response.getheader('Content-Range'),
len(body), 'ACCEPT' if accepted else 'REJECT')
if accepted:
print(body.decode('ascii'))
finally:
conn.close()
finally:
server.shutdown()
server.server_close()
worker.join()实际输出依次为 206 bytes 4-7/16 4 ACCEPT、4567、200 None 16 REJECT、416 bytes */16 0 REJECT。第一项满足已知的四字节区间;第二项虽然成功拿到数据,却是完整响应;第三项没有可追加内容。验证器在本例刻意只接受十六字节资源的固定区间,不能直接当通用 Content-Range 解析器使用。
拒绝之后不要悄悄追加
收到 200 时,安全策略可以是停止续传并报告原因,或者经上层允许后另存为完整文件;不要沿用追加写入模式。遇到 416,不能仅凭本地文件长度看似等于服务器长度,就宣布下载完成。长度一致不代表内容一致,更不证明本地文件来自同一资源版本。
正式实现还应验证区间的数值关系、请求边界及总长,处理缺失字段、超大数字、读取中断和长度不符。失败片段先留在独立临时文件,只有验证通过才提交。下载进度也应在提交成功后推进,否则异常重启可能把一段未经确认的数据当成已经完成。
编码和资源变化是另外两道边界
Range 面向所选表示的字节;若内容带编码,偏移针对编码后的字节序列。示例请求 identity,并拒绝带 Content-Encoding 的响应,避免把压缩数据偏移与解压后的内容混在一起。请求头本身不是保证,实际响应仍要检查,客户端库是否自动解压也需要确认。
本文的资源固定且不变,尚未实现跨版本续传、多区间响应、重定向或持久化恢复。真实下载必须保证各片段属于同一表示,并在完成后校验可信来源提供的整体摘要。只有状态、区间、长度、表示一致性和最终内容验证都闭环,拼接后的文件才值得交给下一步处理。


