A range request asks for part of the selected representation so a client can resume a transfer or read a specific byte window. A satisfied single range normally returns 206 with Content-Range. A server may ignore the range and return the complete representation with 200. A range that cannot be satisfied can instead produce 416; these responses require different client actions.

When it breaks

Blindly appending every successful response corrupts downloads. After a server ignores Range, or an If-Range validator no longer matches, a 200 response contains the full representation. Appending it behind an existing prefix duplicates data. A successful HTTP status does not prove that the body starts at the requested offset, and a familiar filename does not prove representation identity.

Resuming without a suitable validator creates another failure: bytes from two revisions can be combined into one file. If-Range allows the server to send a range only when the representation still matches; otherwise it sends the full representation. An entity tag used here must be strong. A weak tag is not a byte-identity promise, and not every Last-Modified date qualifies as a strong validator.

Range syntax is inclusive and zero-based. Confusing an inclusive end with a length creates an off-by-one error. Suffix ranges refer to the final bytes, while an omitted end extends to the representation’s end. Multiple ranges can produce a multipart body with separate metadata and boundaries; treating that whole body as one contiguous file fragment introduces framing bytes into the download.

Check it quickly

Save this as check-ranges.py and run it with Python, Docker, and the pinned nginx image. The recorded setup used nginx 1.28.0 on Linux arm64. A disposable 64-byte static representation is served through host loopback. No compression is enabled, keeping byte positions tied to the identity representation used by the checksum assertions:

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 returns ten bytes and Content-Range: bytes 10-19/64. Both bytes=60- and bytes=-4 return the final four bytes. Asking for bytes=60-500 still returns bytes 60 through 63, because the valid start is within the representation and the excessive end is bounded by its actual length. Each result checks body bytes and declared length.

The request bytes=100-200 has no satisfiable window in this representation. nginx returns 416 with Content-Range: bytes */64. That response describes the current representation length; it is not another successful fragment to append. A downloader must reconsider its stored offset and version rather than treating every non-206 outcome as an equivalent full-body response.

Two requested windows produce a 206 multipart response. The fixture parses the MIME boundary and verifies each part’s Content-Range and payload separately. It does not assume that the top-level body is a plain concatenation of the requested bytes. This distinguishes a valid multi-range response from a single-range format that happens to have the same status code.

With a matching strong ETag, If-Range returns the requested suffix, and combining it with the stored prefix reproduces the full checksum. A different tag, or the same tag marked weak, yields 200 with the complete body. A separate location sets max_ranges 0, ignores the range, and returns 200; blindly appending that response creates 74 bytes instead of 64.

Make the resume decision before changing the local file

For a single-range resume, verify the status, requested start, returned Content-Range, selected representation, and expected length before committing the fragment. Treat a full 200 as a replacement download. Keep the existing file until the replacement or assembled candidate has been validated, particularly when a failed network transfer could otherwise destroy the only intact local copy.

Keep content coding consistent across attempts. Byte positions apply to the selected representation’s data; transparent decoding and a changed encoding can invalidate assumptions about a stored offset. The fixture avoids that complication deliberately. A production client must establish which bytes it stores and which representation it requests rather than assuming offsets survive every proxy or decoding choice.

Finally, use an independent expected checksum when the download contract provides one. A validator coordinates server-side representation selection; a checksum verifies the assembled bytes against a known target. Neither the existence of Accept-Ranges nor one successful small range proves that a complete interrupted-download workflow handles revisions, replacement responses, and interrupted local writes correctly.

Further reading

Sources