HTTP Range 下载验证:先核对区间,再拼接字节

10-01 4阅读

断点下载最危险的错误,往往不是请求失败,而是把完整响应当成剩余片段接到旧文件后面。Range 是请求意图,下载器还要证明响应满足这次意图。本文依据 RFC 9110,只讨论单个字节区间的验收;示例已在 CPython 3.12.14 本地执行,不请求外部站点,也不写入真实下载文件。

HTTP Range 下载验证:先核对区间,再拼接字节

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 的响应,避免把压缩数据偏移与解压后的内容混在一起。请求头本身不是保证,实际响应仍要检查,客户端库是否自动解压也需要确认。

本文的资源固定且不变,尚未实现跨版本续传、多区间响应、重定向或持久化恢复。真实下载必须保证各片段属于同一表示,并在完成后校验可信来源提供的整体摘要。只有状态、区间、长度、表示一致性和最终内容验证都闭环,拼接后的文件才值得交给下一步处理。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。