「大容量ファイルを再開可能に取得するHTTP Range設計|206・Content-Range・If-Range」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

大容量ファイルを再開可能に取得するHTTP Range設計|206・Content-Range・If-Range

HTTP Rangeで大容量ファイルを安全に再開取得するため、206・Content-Range・If-Range・416、途中ファイル管理、SHA-256検証、Python実装、CAD連携への再利用設計を解説します。

DOC NO.EKP-00-1085
DATE
REVB
READ約 15 分

大容量のCADモデル、解析結果、バックアップ、学習用データをHTTPで取得するとき、通信が途中で切れるたびに最初から取り直すと、時間と転送量を浪費します。HTTPのRange requestを使えば、既に保存したbyteの次から取得を再開できます。

ただし、単にRange: bytes=途中位置-を付けて追記するだけでは危険です。serverがRangeを無視して全体を200 OKで返す場合や、再開前にfileが更新される場合があります。古い前半と新しい後半を結合すると、開けても内容が壊れたfileになります。

この記事では、206 Partial Content、Content-Range、If-Range、416 Range Not Satisfiableを一つの契約として扱い、安全に再開できるdownload設計を整理します。

目次

Range requestで解決すること

Range requestは、GETで選択されたrepresentation全体ではなく、その一部分を要求する仕組みです。再開時は、途中fileの実際のsizeを次の開始offsetとして送ります。

GET /files/model-r12.step HTTP/1.1
Host: files.example.com
Range: bytes=104857600-
If-Range: "model-r12-7f3a"
Accept-Encoding: identity
Authorization: Bearer <access-token>

bytes=104857600-は、0から数えて104,857,600番目のbyteから末尾までを要求します。byte位置は0始まりで、範囲の開始と終了はともに範囲へ含まれます。

HTTPのRangeはGETに対して定義されています。uploadの途中再開や、任意位置へのPUTを共通仕様として保証するものではありません。uploadは別のprotocolやAPI契約として設計します。

正常系のrequestとresponse

serverが要求を満たすと、全体ではなく一部を示す206 Partial Contentを返します。

HTTP/1.1 206 Partial Content
Content-Type: application/step
Content-Range: bytes 104857600-209715199/734003200
Content-Length: 104857600
ETag: "model-r12-7f3a"
Accept-Ranges: bytes

<104857600 bytes of binary data>

Content-Rangeの各値には次の意味があります。

要素例意味
unitbytes範囲の単位
first-pos104857600今回のbodyの先頭offset
last-pos209715199今回のbodyの末尾offset。範囲に含む
complete-length734003200選択されたrepresentation全体のbyte数

今回のbodyに期待するbyte数は、last-pos - first-pos + 1です。clientはstatusだけでなく、開始位置、終了位置、全体size、実際に受け取ったbyte数を照合してから途中fileへ追記します。

Accept-Ranges: bytesは、対象resourceがbyte rangeを扱えることをserverが知らせるhintです。clientはこのheaderがなくてもRangeを試せます。一方、headerがあっても次回必ず206になる保証はないため、responseごとの検証は省略できません。

再開状態をfileとは別に保持する

途中fileだけでは、どの版の続きを取得すべきか判断できません。業務上の識別子とHTTP validatorをmetadataとして分けて保存します。

{
  "resource_id": "cad-model-0184",
  "revision": "R12",
  "etag": "\"model-r12-7f3a\"",
  "total_bytes": 734003200,
  "partial_bytes": 104857600,
  "sha256": "5df6e0e2761359d30a8275058e299fcc038153454f6c6a30e1d8bf178a6e7e91"
}

partial_bytesは便宜的な記録であり、再開時は途中fileの実sizeを正とします。process停止直前にmetadataだけ更新され、file書込が完了していない可能性があるためです。

認証tokenや署名付きURLをmetadataへ保存しません。URLのqueryにcredentialが含まれるserviceでは、安定したresource_idから実行時に新しいURLを取得します。

If-Rangeで版の混在を防ぐ

同じURLの内容が更新され得る場合、offsetだけを送ると古い前半と新しい後半が混ざります。If-Rangeは、保存済みのvalidatorと現在のrepresentationが一致するときだけRangeを適用する条件です。

validatorが一致する
  → 206で不足部分を返す

validatorが一致しない
  → Rangeを無視し、200で現在の全体を返す

clientは初回responseの強いETagを保存し、再開時に同じ値をIf-Rangeへ渡します。W/で始まる弱いETagは使えません。ETagがない場合はLast-Modifiedを使えることもありますが、RFC 9110では強いvalidatorとして扱える日時に限られます。更新されないrevision別URLやcontent hashを含むURLを用意できるなら、その方が契約を単純にできます。

重要なのは、Range付きrequestに200 OKが返ったときです。これは「続き」ではなく全体なので、既存の途中fileへ追記してはいけません。一時fileをtruncateして先頭から書き直します。

statusごとの処理を固定する

再開clientはstatus codeとheaderを組み合わせて処理します。

status主な意味clientの処理
206要求した部分が返ったContent-Rangeを検証し、開始位置が一致するときだけ追記
200Rangeが無視された、またはIf-Range不一致途中fileを上書きして全体を保存
416指定rangeが現在の範囲外などContent-Range: bytes */totalとlocal sizeを比較
401 / 403認証または認可に失敗credentialを更新し、bodyをfileへ書かない
404 / 410対象がない、または消失自動再試行せずresource状態を確認
5xxserver側の一時障害の可能性上限付きbackoff後に同じoffsetから再試行

416でContent-Range: bytes */734003200が返り、local sizeも734,003,200なら、前回のbody受信後に完了処理だけ失敗した可能性があります。最終hashを検証して完了へ移せます。local sizeが全体sizeより大きい、またはvalidatorが一致しないなら、壊れた途中fileとして隔離または再取得します。

serverはRangeを無視できるため、範囲外でも必ず416になるとは限りません。200を受け取れる設計が必要です。

PythonでContent-Rangeを検証する

次の関数は、単一byte rangeのresponseを検証します。networkやfile I/Oから分離しているため、正常系と異常系を決定的にtestできます。

import re
from dataclasses import dataclass


CONTENT_RANGE_RE = re.compile(
    r"^bytes (?P<start>\d+)-(?P<end>\d+)/(?P<total>\d+|\*)$"
)


@dataclass(frozen=True)
class PartialResponse:
    start: int
    end: int
    total: int | None

    @property
    def body_bytes(self) -> int:
        return self.end - self.start + 1


def validate_partial_response(
    status: int,
    content_range: str | None,
    requested_start: int,
) -> PartialResponse:
    if status != 206:
        raise ValueError("partial response must use status 206")
    if content_range is None:
        raise ValueError("206 response requires Content-Range")

    match = CONTENT_RANGE_RE.fullmatch(content_range.strip())
    if match is None:
        raise ValueError("invalid Content-Range")

    start = int(match.group("start"))
    end = int(match.group("end"))
    total_text = match.group("total")
    total = None if total_text == "*" else int(total_text)

    if start != requested_start:
        raise ValueError("response start does not match local file size")
    if end < start:
        raise ValueError("range end is before start")
    if total is not None and total <= end:
        raise ValueError("complete length must be greater than range end")

    return PartialResponse(start=start, end=end, total=total)

実際のdownload処理では、途中fileのsizeをrequested_startへ渡し、返り値のbody_bytesとstreamから実際に書いたbyte数を一致させます。不一致なら途中fileを完成扱いにせず、最後に成功したsizeから再試行します。

request headerは次のように組み立てられます。

def resume_headers(local_size: int, etag: str | None) -> dict[str, str]:
    headers = {"Accept-Encoding": "identity"}

    if local_size > 0:
        headers["Range"] = f"bytes={local_size}-"
        if etag is not None:
            if etag.startswith("W/"):
                raise ValueError("weak ETag cannot be used with If-Range")
            headers["If-Range"] = etag

    return headers

Accept-Encoding: identityは、圧縮されたbyte列と展開後のbyte列を混同しにくくするための指定です。RFC 9110では、content codingが適用されたrepresentationのRangeは符号化後のbyte列に対して計算されます。serverやintermediaryが異なるcodingを返す可能性も考え、同じvalidatorとrepresentation metadataを照合します。

fileへ安全に書き込む

完成名へ直接追記すると、別processが未完成fileを読み始める可能性があります。.partなどの一時名へ保存し、検証後に完成名へ原子的に置き換えます。

response headerを検証
  ↓
.partへ追記または200なら上書き
  ↓
書込byte数とtotal sizeを検証
  ↓
SHA-256を全体に対して再計算
  ↓
期待hashと一致したら完成名へ置換

hashは途中blockだけでなく完成file全体に対して計算します。Pythonではhashlib.file_digest()を使えます。

import hashlib
from pathlib import Path


def sha256_file(path: Path) -> str:
    with path.open("rb") as file_obj:
        return hashlib.file_digest(file_obj, "sha256").hexdigest()

serverがhashを提供しない場合でも、取得側でhashを記録すれば、後続のDB登録、CAD変換、archive展開で同じfileを識別できます。ただし、改ざん検知に使う期待hashは、file本体とは別の信頼できる経路から取得します。

冪等性と再実行

GETはHTTP上で冪等ですが、localへの書込処理が自動的に安全になるわけではありません。再実行時の規則を固定します。

  • 再開offsetは途中fileの実sizeから求める
  • 206は開始offsetが一致した場合だけappendする
  • 200は必ず一時fileを先頭から置き換える
  • 同じ区間を二重にappendしないよう、書込完了後にsizeを確定する
  • 完成判定はtotal sizeとhashの両方で行う
  • 完成済みhashが期待値と一致するなら、同じjobを再実行しても再downloadしない
  • 複数workerが同じresourceを取得しないよう、job IDやfile lockで排他する

DBにはresource_id、revision、期待hash、local path、取得済みbyte数、ETag、status、最終試行日時を分けて保存します。pathだけを一意性の根拠にせず、業務IDとversionを一意制約へ含めます。

失敗例と落とし穴

200を途中fileへ追記する

Rangeを無視した全体responseを既存fileへappendすると、前半の後ろに完全なfileが続くためsizeが過大になります。statusに応じてappendとoverwriteを分けます。

Content-Lengthだけを見る

Content-Lengthは今回のmessage bodyの長さであり、必ずしも元file全体のsizeではありません。206ではContent-Rangeのcomplete-lengthを別に確認します。

ETagを保存しない

URLとsizeが同じでも内容が変わることがあります。validatorなしで可変resourceを再開しないか、revision別のimmutable URLを使います。

offsetとbyte数を混同する

0から99までの範囲は100 byteです。end - startではなくend - start + 1で計算します。

文字列として結合する

CAD、ZIP、Parquetなどはbinary dataです。text mode、文字コード変換、改行変換を通さず、byte streamとして保存します。

セキュリティと運用

Rangeは便利ですが、多数の小さな範囲や重複範囲はserver負荷を増やします。server側ではrange数、request数、同時接続、response sizeを制限し、異常なpatternを拒否します。単純な再開では一つの連続rangeだけを要求する方が扱いやすくなります。

client側では次を守ります。

  • HTTPSを使い、redirect先でも認証情報を不用意に転送しない
  • Authorization、署名付きURL、cookieを通常logへ残さない
  • response sizeに上限を設け、想定外の200でdiskを使い切らない
  • 保存先を業務IDから直接組み立てず、許可したdirectory内に固定する
  • hash不一致fileを完成名へ昇格しない
  • retry回数、総時間、同時download数を制限する
  • archiveはdownload検証後に別工程で展開し、path traversalや展開後sizeを検査する

Rangeは認証や認可を置き換えません。途中位置を知っていても、resourceへの読取権限がなければserverは拒否します。

CAD・DB・API連携へ再利用する

CAD model、点群、解析結果、BOM archiveのような大容量fileでは、file本体を業務recordへ埋め込まず、安定したasset ID、revision、media type、size、hash、取得URLをmetadataとして管理できます。

download完了後にhashをkeyとして重複排除し、DBへ取得結果を登録します。後続のCAD変換や解析jobは完成statusとhashを条件に開始し、.partを参照しません。URLが期限切れになっても、asset IDとrevisionから新しいURLを発行できれば同じ処理を再開できます。

Range、validator、hash、業務IDの役割を分けると、保存先がlocal disk、object storage、cacheのどれでも同じ契約を使えます。

まとめ

HTTP Rangeによる再開downloadは、offsetを送るだけでは安全になりません。部分responseと保存済みfileが同じrepresentationであることを検証する必要があります。

  • Range: bytes=<local-size>-で不足部分を要求する
  • byte offsetは0始まりで、開始・終了位置は範囲に含まれる
  • 206ではContent-Rangeの開始位置、終了位置、全体sizeを検証する
  • 強いETagをIf-Rangeへ渡し、版の混在を防ぐ
  • 200は全体responseとして上書きし、途中fileへ追記しない
  • 416ではserverの全体sizeとlocal sizeを比較する
  • 一時fileへ書き、sizeとSHA-256確認後に完成名へ置き換える
  • tokenや署名付きURLをmetadataとlogから分離する

この設計なら、通信断やprocess再起動が起きても取得済みbyteを再利用しつつ、古い版と新しい版を誤って結合する事故を防げます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次