HTTPのRangeとIf-Range、途中からの取得で別の版を混ぜないために

対応する波模様を持つ淡い緑の二つの帯が隙間を挟んで並び、その奥に途切れない桃色の帯が流れる抽象的な構図。

ダウンロードが途中で切れたとき、すでに受け取った分を残し、続きだけを取得できれば通信量を減らせます。ただし、同じURLに置かれた内容が再開までに更新されていたらどうなるでしょうか。前半は古い版、後半は新しい版という、どちらにも存在しない内容を作ってしまう可能性があります。HTTPのRangeとIf-Rangeは、取得する位置と、つなぎ合わせてよい版を分けて考えるための仕組みです。[1]

簡単にまとめると?

  • Rangeは、選択された表現の一部分を要求します。バイト位置はゼロから数え、終端も範囲に含みます。
  • 続きの位置が合っていても、前半と後半の版が同じとは限りません。長さだけの確認では更新を見落とします。
  • 強いETagを使ったIf-Rangeが一致しなければ、サーバーはRangeを無視します。通常の全体応答を受けた側は、古い断片への追記ではなく置き換えを判断します。
  • 部分取得への対応は保証ではありません。206・200・416とContent-Rangeを確認し、実際に返った範囲を扱います。

「400バイト目から」は、どの内容の400バイト目か

HTTPでいう表現は、ある資源を特定の形式で表したデータです。同じURLでも、更新時刻や要求の条件によって返る表現が変わることがあります。Rangeは、その選択された表現の一部分だけを送るよう要求するフィールドです。本稿では、GETで一つの連続したバイト範囲を要求する場合に絞ります。

全体が1,000バイトで、先頭の400バイトを保存済みなら、保存した位置は0から399です。残りは400から999なので、要求はRange: bytes=400-999と書けます。終端を含むため、長さは999−400+1で600バイトです。[1、14.1.2節] この位置計算が正しくても、再開先が同じ版かどうかは別の問題として残ります。

たとえば、古い版Aも新しい版Bも1,000バイトだったとします。途中で内容だけが変わった場合、Aの先頭400バイトへBの残り600バイトを追加すれば、長さは正しく1,000になります。しかし、それだけではAともBとも一致しません。「予定の容量まで届いた」という確認は、同じ版の完成を意味しないのです。

同じ長さでも、つなぎ合わせた結果が違う

この違いを独自のバイト列モデルで確かめます。位置iに対して、版Aはi mod 251、版Bは(i+17) mod 251という値を入れ、それぞれ1,000バイトを作りました。ここでmodは割った余りです。実在ファイルの測定ではなく、版の混在を見えるようにした説明用のデータです。

Aの先頭400バイトとAの残り600バイトを連結すれば、元のAと全バイトが一致します。一方、Aの先頭400バイトとBの残り600バイトを連結すると、長さは1,000のままでも、AにもBにも一致しません。計算ではバイト列そのものの比較とSHA-256の比較を行い、この三つを区別しました。以下の図の「正しい」は、対象にした版の全体と一致するという意味です。

独自の1,000バイトモデル。同じ版Aの先頭400と残り600はAへ復元できる。Aの先頭400と版Bの残り600は長さ1,000でも混在する。版が変わった際にBの全体1,000を受け直すとBと一致する。
位置と全体長だけでは版の一致を判定できない。図はバイト列の連結モデルであり、HTTPサーバーの動作試験や転送速度の測定ではない。

この例では、同じ版を再開できれば新しく受け取るデータ本体は600バイトです。版が変わり、全体を受け直すなら1,000バイトになります。ここで比較しているのはデータ本体の長さだけで、ヘッダー、暗号化の付加情報、再送、圧縮や通信時間は含みません。少ない転送量と、正しい版の完成は、同じ判断ではありません。

If-Rangeは、版が違うときにRangeを外す

そこで、最初の応答で得た強いETagを保存しておきます。ETagはサーバーが表現へ付ける検証用の値で、ファイルの内容を利用者が勝手に推測して作る名前ではありません。ここでは版Aの値を"version-A"とした説明例を使います。再開時にRangeと併せてIf-Range: "version-A"を送ると、その検証値が現在の表現と一致する場合に範囲要求を扱う条件を付けられます。[1、13.1.5節]

現在もAで、サーバーが範囲要求を処理できるなら、残りを206 Partial Contentで受け取る流れになります。現在がBへ変わっていて検証値が一致しなければ、サーバーはRangeを無視しなければなりません。ほかに条件がなく通常のGETが成功する例では、B全体を200 OKで返すことになります。If-Rangeの不一致それ自体を、必ず412で止める仕組みとして読むのは誤りです。

受信側にも分岐が必要です。206で期待する範囲と同じ強い検証値を確認できたときは、保存済みの断片と結合できます。200で全体が返ったときに、その全体を古い400バイトの後ろへ機械的に追加してはいけません。この例なら、Aの断片を採用せず、返されたB全体を新しい結果として扱います。RFCは複数の部分応答を結合する際、同じ強い検証値を共有することを求めています。[1、15.3.7.3節]

W/を伴う弱いETagは、If-Rangeへ指定できません。内容が意味の上では同等でも、バイト単位でそのまま接続できる保証とは違うためです。If-Rangeには日付を使う形式もありますが、利用できる条件が別にあるため、ここでは強いETagを保持する方式だけを扱います。

応答コードと範囲を、セットで確認する

一つの範囲を返す206では、Content-Range: bytes 400-999/1000のように、返した位置と全体長を示します。この例でContent-Lengthが600なら、それは今回の応答本体の長さであり、元の表現全体が600バイトという意味ではありません。要求した範囲を送ったはずだと思い込まず、返されたフィールドと保存位置を照合します。[1、14.4節・15.3.7節]

一方、サーバーはRangeを無視することもできます。Accept-Ranges: bytesという案内を以前に受け取っていても、将来のすべての要求へ部分応答を約束するわけではありません。この案内がなくても範囲要求はできますが、どちらの場合も206だけを前提に実装しないことが大切です。[1、14.2節・14.3節]

指定した範囲が現在の表現に存在しないときなどには、416 Range Not Satisfiableが返ることがあります。現在の長さが1,000なら、Content-Range: bytes */1000によってその長さが示される場合があります。ただし、416だけで「保存したデータは完成している」と結論してはいけません。保存断片の版と位置を調べ直す必要があります。また、Rangeを無視して200を返す実装もあるため、範囲外なら必ず416になるという保証でもありません。[1、15.5.17節]

圧縮や更新の条件まで揃えて再開を判断する

ここまでのモデルは、圧縮などの内容符号化を使わず、取得中には版が変わらない場合を一回ずつ比較しました。実際に内容符号化が施された表現では、バイト範囲は符号化後のバイト列を対象とします。展開したファイルの400バイト目を、圧縮された転送内容の400バイト目へそのまま読み替えることはできません。[1、14.1.2節]

再開機能を評価するときは、取得した位置だけでなく、保存した表現の検証値、符号化の条件、返ってきた応答コードとContent-Rangeを確認します。更新された全体へ置き換える分岐、範囲が利用できない場合の再取得、検証値を失った場合の扱いも必要です。Rangeは通信量を減らせる道具ですが、「同じ版の続きだと確認できる条件」を併せて持つことで、部分取得を正しい完成につなげられます。

出典

  1. RFC 9110「HTTP Semantics」、2022年6月、STD 97。13.1.5 If-Range、14.1.2 Byte Ranges、14.2 Range、14.3 Accept-Ranges、14.4 Content-Range、15.3.7 Partial Content、15.3.7.3 Combining Parts、15.5.17 Range Not Satisfiable。公式本文。数値例・バイト列の比較・図はQuantum Sea独自のモデル。複数範囲のmultipart応答、実サーバーの実装検証、速度測定は本稿の対象外。