范围请求要求获取选中表示的一部分,用于恢复中断传输或读取指定字节窗口。满足单个范围时,通常返回带有 Content-Range 的 206。服务器也可以忽略范围,返回完整表示和 200;如果范围无法满足,还可能返回 416。这些响应要求客户端采取不同动作,不能把所有“没有拿到预期范围”的情况都解释成同一种完整下载。

坏掉的样子

把每个成功响应都直接追加,会破坏下载文件。当服务器忽略 Range,或者 If-Range 验证器不再匹配时,200 响应包含完整表示。把它接在已有前缀后面,就会重复数据。HTTP 成功状态不能证明正文从请求的偏移开始,熟悉的文件名也不能证明表示身份。修改本地文件之前,必须先判断这次得到的是片段还是整份替代内容。

缺少合适验证器时,续传还可能把两个版本的字节组合成一个文件。If-Range 允许服务器仅在表示仍匹配时返回范围,否则发送完整表示。用于该条件的实体标签必须是强标签;弱标签不是字节身份承诺,也不是所有 Last-Modified 日期都能充当强验证器。是否适合用作续传条件,需要符合协议规则和服务器真实更新行为。

其他请求前提条件先于 Range 评估。本来应返回 304 或 412 的请求,不会因为同时带有 Range 就成为成功的范围传输。下面实验没有发送相互竞争的前提条件;把其预期状态用于下载器时,要保留这个限制,不能把它当成所有条件组合下的完整状态表。

范围坐标从零开始,末尾位置也包含在内。把末尾位置误当成长度,会产生差一个字节的问题。后缀范围表示最后若干字节,省略末尾则一直延伸到表示结尾。多个范围可能返回带独立元数据和边界的 multipart 正文;如果把整份正文当成连续文件片段写入,就会把分隔信息也混进下载内容,虽然状态码仍然是 206。

快速验证

将脚本保存为 check-ranges.py,准备 Python、Docker 和固定摘要的 nginx 镜像后执行。记录环境为 Linux arm64 上的 nginx 1.28.0。实验通过宿主回环提供一个可丢弃的 64 字节静态表示,不启用压缩,使范围位置始终对应摘要断言使用的原始表示,避免把内容编码变化混入基本范围测试:

from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid

NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def wait(predicate):
    deadline=time.monotonic()+15
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except OSError:pass
        time.sleep(.1)
    raise AssertionError('fixture did not become ready')

@contextmanager
def lab(configuration,files=None,backend=None):
    name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
    try:
        with TemporaryDirectory(prefix=name+'-') as directory:
            root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
            for path,content in (files or {}).items():
                target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
                target.write_bytes(content if isinstance(content,bytes) else content.encode())
            docker('network','create',name)
            if backend is not None:
                (root/'backend.py').write_text(backend)
                (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
                docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
                docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
                wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
            (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
            docker('build','--network','none','-t',tags[0],str(root))
            docker('run','--rm','--network',name,tags[0],'nginx','-t')
            docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
            details=json.loads(docker('inspect',containers[0]).stdout)[0]
            binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
            def ready():
                connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
            wait(ready)
            def request(path,method='GET',headers=None,body=None):
                connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
                try:
                    connection.request(method,path,body=body,headers=headers or {})
                    response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
                finally:connection.close()
            request.port=port
            yield containers[0],request
    finally:
        docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)

def config(locations,extra=''):
    return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
from email import policy
from email.parser import BytesParser
import hashlib
payload=('0123456789'*6+'ABCD').encode()
with lab(config('location = /whole.bin { max_ranges 0; }'),{'asset.bin':payload,'whole.bin':payload}) as (_,request):
    status,headers,body=request('/asset.bin');assert status==200 and body==payload
    tag=headers['etag'];results={};partials={
        'bytes=10-19':(10,19),'bytes=60-':(60,63),'bytes=-4':(60,63),'bytes=60-500':(60,63)}
    for value,(start,end) in partials.items():
        status,h,b=request('/asset.bin',headers={'Range':value})
        assert status==206 and h['content-range']==f'bytes {start}-{end}/64' and b==payload[start:end+1]
        assert int(h['content-length'])==len(b);results[value]={'status':status,'content_range':h['content-range']}
    status,h,_=request('/asset.bin',headers={'Range':'bytes=100-200'})
    assert status==416 and h['content-range']=='bytes */64';results['unsatisfiable']={'status':status,'content_range':h['content-range']}
    status,h,b=request('/asset.bin',headers={'Range':'bytes=0-2,60-63'})
    assert status==206 and h['content-type'].startswith('multipart/byteranges;')
    message=BytesParser(policy=policy.default).parsebytes(('Content-Type: '+h['content-type']+'\r\n\r\n').encode()+b)
    parts=list(message.iter_parts());assert len(parts)==2
    assert [(p['Content-Range'],p.get_payload(decode=True)) for p in parts]==[('bytes 0-2/64',payload[:3]),('bytes 60-63/64',payload[60:])]
    results['multipart']=[p['Content-Range'] for p in parts]
    for name,value,expected in [('match',tag,206),('mismatch','"different"',200),('weak','W/'+tag,200)]:
        status,h,b=request('/asset.bin',headers={'Range':'bytes=10-','If-Range':value})
        assert status==expected and b==(payload[10:] if expected==206 else payload)
        results['if_range_'+name]=status
        if name=='match':assert hashlib.sha256(payload[:10]+b).digest()==hashlib.sha256(payload).digest()
    status,h,b=request('/whole.bin',headers={'Range':'bytes=10-','If-Range':tag})
    assert status==200 and b==payload and 'content-range' not in h
    assert hashlib.sha256(payload[:10]+b).digest()!=hashlib.sha256(payload).digest()
    results['ranges_disabled']={'status':status,'naive_appended_length':len(payload[:10]+b)}
    print(json.dumps(results,sort_keys=True))

bytes=10-19 返回十个字节,响应头为 Content-Range: bytes 10-19/64。bytes=60- 和 bytes=-4 都返回最后四个字节。请求 bytes=60-500 也返回第 60 到 63 字节,因为起始位置有效,而过大的结束位置受到真实表示长度限制。每个结果同时核对正文字节和声明长度,不仅断言状态码为 206。

请求 bytes=100-200 在这份表示中没有可满足窗口,nginx 返回 416,并带有 Content-Range: bytes */64。这个响应给出当前表示长度,不是又一块可追加的成功片段。下载器需要重新检查已存偏移和版本,不能把所有非 206 结果都视为等价的完整正文,也不能仅因为本地已有数据就假定继续追加一定合理。

请求两个窗口会产生 206 multipart 响应。实验解析 MIME 边界,分别检查每部分的 Content-Range 和负载,绝不假设顶层正文只是所需字节的直接拼接。这样才能区分合法多范围响应与单范围格式:二者状态码相同,但正文组织形式不同。解析边界本身也是正确提取范围数据的一部分。

携带匹配强 ETag 的 If-Range 返回所需后缀,把它与已存前缀组合后,摘要与完整文件一致。标签不同,或同一标签被标记为弱标签时,则返回 200 和完整正文。另一个 location 设置 max_ranges 0,直接忽略范围并返回 200;盲目把这份响应追加到旧前缀后,文件会变成 74 字节,而正确长度只有 64。

修改本地文件前,先决定如何处理续传结果

对于单范围续传,应先验证状态、请求起点、返回的 Content-Range、所选表示和预期长度,再提交片段。完整 200 应作为替代下载处理。在替代文件或组装候选文件验证完成前,保留已有文件,尤其要避免一次网络失败就破坏本地唯一完整副本。状态判断与文件写入次序共同影响最终数据完整性。

不同尝试之间还要保持内容编码一致。字节位置针对所选表示的数据;透明解码或编码变化,可能让已经保存的偏移假设失效。实验有意排除这一复杂情况。真实客户端必须明确自己保存哪一种字节,以及请求哪一种表示,不能假定偏移能穿过任意代理、压缩和解码选择仍保持含义不变。

如果下载契约提供独立预期摘要,最终应据此校验。验证器协调服务器选择同一个表示,摘要则验证组装后的字节是否符合已知目标。仅看到 Accept-Ranges,或者成功读取一个很小的范围,都不能证明完整续传流程正确处理了版本变化、完整替代响应和本地写入中断。这些情况应按实际下载流程覆盖,而不是由单个成功片段推断。

延伸阅读

来源