← 中文资源

简体中文版本

HTTP Range 断点续传下载

使用 Range 请求续传中断的下载,只追加缺失字节,服务器忽略时自动重下。

阅读英文原文

功能说明

这段代码演示断点续传:目标文件已经存在一部分时,客户端发送 Range: bytes=<已有长度>-,服务器返回 206 和剩余内容,客户端以追加模式写入;如果服务器忽略 Range 返回 200,则从头覆盖,避免把完整文件追加到旧文件后面。

示例自带一个最小的 Range 服务器,用内存中的 10000 字节负载验证逻辑,因此不依赖外部网络,适合在测试或离线环境中理解协议行为。

关键代码

download_with_resume 先读取目标文件大小,非零时添加 Range 头。响应状态为 206 时用 ab 追加,否则用 wb 截断并把 existing 重置为零。循环按块读取,避免一次性把大文件放进内存。

RangeHandler 解析 bytes=起始-,返回 206、Content-Length 和 Content-Range;main 预写前 5000 字节,再调用下载函数,断言最终内容与完整负载逐字节一致。

使用注意

真实服务可能返回 416(范围不可满足)或压缩后的内容,需要单独处理。为了确认远端资源没有变化,应结合 ETag 或 If-Range,否则续传可能把两个版本的字节拼在一起。下载完成后最好校验长度和哈希,而不只看文件大小。

本地测试服务器不支持多段 Range、If-Range 和鉴权,生产代码应使用成熟客户端并设置连接、读取超时。临时文件建议先写 .part,校验通过后再原子重命名。

验证方式

目标文件预先写入 PAYLOAD[:5000],下载函数返回 10000,读取结果与 PAYLOAD 完全相等,说明只补写了后 5000 字节。可以修改处理器让它在收到 Range 时仍返回 200,观察客户端会重新完整下载而不是产生 15000 字节的错误文件。

代码示例

from __future__ import annotations

import http.server
import os
import tempfile
import threading
import urllib.request

PAYLOAD = b"0123456789" * 1000


class RangeHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        header = self.headers.get("Range", "")
        start = 0
        if header.startswith("bytes="):
            start = int(header.removeprefix("bytes=").split("-", 1)[0] or 0)
        body = PAYLOAD[start:]
        self.send_response(206 if start else 200)
        self.send_header("Content-Length", str(len(body)))
        if start:
            self.send_header(
                "Content-Range",
                f"bytes {start}-{len(PAYLOAD) - 1}/{len(PAYLOAD)}",
            )
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: object) -> None:
        return


def download_with_resume(url: str, destination: str, chunk_size: int = 4096) -> int:
    existing = os.path.getsize(destination) if os.path.exists(destination) else 0
    request = urllib.request.Request(url)
    if existing:
        request.add_header("Range", f"bytes={existing}-")

    with urllib.request.urlopen(request, timeout=5) as response:
        if existing and response.status == 206:
            mode = "ab"
        else:
            mode = "wb"
            existing = 0
        with open(destination, mode) as handle:
            while True:
                chunk = response.read(chunk_size)
                if not chunk:
                    break
                handle.write(chunk)
    return os.path.getsize(destination)


def main() -> None:
    server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RangeHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        with tempfile.TemporaryDirectory() as raw_dir:
            destination = os.path.join(raw_dir, "payload.bin")
            with open(destination, "wb") as handle:
                handle.write(PAYLOAD[:5000])

            url = f"http://127.0.0.1:{server.server_address[1]}/payload.bin"
            size = download_with_resume(url, destination)
            with open(destination, "rb") as handle:
                downloaded = handle.read()
            print(f"downloaded {size} bytes")
            assert downloaded == PAYLOAD
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)


if __name__ == "__main__":
    main()