HTTP 304 に本文がないとき、クライアントは内容をどこから取り出すのか
304 Not Modified を見たとき、見落としやすい問いがあります。今回の応答に本文がないなら、次に表示する画像、テキスト、データはどこから来るのでしょうか。自作のダウンロードスクリプトがETagだけを保存し、再リクエストで新しい出力ファイルを得られなかったら、取得は成功でしょうか、失敗でしょうか。
答えは、クライアントが何を保存しているかによります。304は、以前保存した適用可能な内容を再利用させる応答です。その内容をヘッダーへ隠して再送するわけではありません。 検証情報、保存済み本文、キャッシュ方針は関連しますが、個別に扱う必要があります。
2026年10月13日、curlでこのブログの公開テキストファイルへ五種類の読み取り専用GETを行い、条件、ステータス、本文サイズ、応答ヘッダーを記録しました。以下の値は今回の実行結果で、プロトコルの意味は規格と公式文書に基づきます。ブラウザーのディスクキャッシュ、CDN、同時負荷、サイト全体の性能は調べていません。
新しい内容の受信と、保存済み内容の再利用を分ける
HTTPキャッシュを管理するクライアントが保存するのは、検証用タグだけでなく、受信済みの内容と関連情報です。再利用する際、保存した版を今も使えるか、サーバーへ問い合わせる必要がある場合があります。
ETagで検証するGETでは、保存したタグを If-None-Match で送ります。現在選択される表現とタグが一致すると、サーバーは304を返して本文の再送を省けます。条件が通常の取得を許す場合、この例では本文を伴う200になります。MDN:304と条件付きリクエスト
RFC 9110は304が内容を含められないと規定します。空の本文は、ファイルが空になった証拠ではありません。手元に使える保存内容があって、初めて再利用できます。RFC 9110:304 Not Modified
同じ公開ファイルを繰り返し読むとき、既存の内容が引き続き適用できると確認し、ファイル全体を重ねて転送しない用途に役立ちます。ただし今回もHTTPの要求と応答があり、ヘッダーを転送します。本文ゼロを、通信していないという意味にはできず、遅延や総通信量がどれほど減ったかも直接は導けません。
実際のファイルへ五回リクエストする
対象はこのブログの読図メモです。環境は macOS 27.0.1、Python 3.9.6、curl 8.7.1 です。HTTP/1.1に固定し、curlの圧縮デコード用オプションも -I も使わず、毎回GETを送りました。
初回のETagは "6accd07a-b5a"、本文は 2,906バイトでした。タグは実行時の記録で、サービスの更新に伴って変わり得ます。再実行ではその時点のタグを読み、昔の値を直接コピーしないでください。
| 条件 | 初回の本文は手元に残っているか | 今回のステータス | 今回取得した本文 |
|---|---|---|---|
| 条件なしで、本文とETagを保存 | いいえ、まだ取得していない | 200 | 2,906バイト |
| ファイルから一致するETagを読み込む | はい | 304 | 0バイト |
| 一致しないタグを意図的に送る | はい | 200 | 2,906バイト |
同じタグへ W/ を付ける | はい | 304 | 0バイト |
| 初回本文を削除し、タグのファイルを残す | いいえ | 304 | 0バイト |
各リクエストには別の出力ファイル名を使います。今回の三つの304では、新しい本文ファイルは作られませんでした。ファイルの有無と取得バイト数は別々に添付記録へ残しています。今回のcurl呼び出しの観察で、すべてのツールのファイル作成に同じ規則を当てはめるものではありません。
不一致タグの行でも、内容の更新は起きていません。本文のSHA-256は初回と同じです。現在の表現に対応しない条件を送ったため、サーバーが完全なテキストを返しました。200を受け取っただけでは、サーバー上のファイルが直前に変わったと断定できません。
curlの size_download を出力ファイルのバイト数と照合しました。数えるのは応答本文で、ヘッダー、TLS、そのほかのネットワーク負担は含みません。時間も計測していないため、表は何を転送したかを示し、速度向上率は示しません。
ヘッダーには別の確認可能な情報もあります。初回200の Date は 12:05:35 GMT、一致タグの304は 12:05:38 GMT で、ETagと Last-Modified は同じでした。新しい応答日時と変わらない検証情報は共存できます。前者は今回の応答に属し、本文の更新記録とは解釈できません。各回のヘッダーを残すのは、情報が何に由来するかを区別できるようにするためです。RFC 9110:Dateの意味
curlでタグを保存しても、完全なキャッシュにはならない
新しく作った空のディレクトリで、最初の二段階を手動で再実行できます。
gumi_demo_url='https://gumi.ink/examples/great-wave-reading-notes.txt'
curl --http1.1 --silent --show-error --fail --max-time 20 \
--dump-header unconditional.headers --output unconditional.body \
--etag-save etag.txt \
--write-out 'status=%{http_code} body=%{size_download}\n' "$gumi_demo_url"
curl --http1.1 --silent --show-error --fail --max-time 20 \
--dump-header matching_tag.headers --output matching_tag.body \
--etag-compare etag.txt \
--write-out 'status=%{http_code} body=%{size_download}\n' "$gumi_demo_url"
今回対応する結果は、最初が status=200 body=2906、次が status=304 body=0 です。本文とETagは用途の違うファイルへ書きます。前者はデータ、後者は次の検証で送る値です。
curlの文書では、--etag-save は応答のETagを保存し、--etag-compare はファイルのタグを読んで If-None-Match を送ります。いずれも 7.68.0 から利用できます。アプリに代わって古い本文を探したり、完全なキャッシュ項目を管理したり、保存済み本文を新しいファイルへ出力したりする機能ではありません。curl:ETagの保存と条件付き比較
この手動例では二回目の後に読むのは、以前の unconditional.body です。新しい出力が存在しないことをテキストの削除と解釈せず、更新のつもりで既存の内容をゼロバイトで上書きしないようにします。先にステータスを読み、使用する本文を決めると、交換の意味に合います。
本文を失ったことは、タグからサーバーには伝わらない
五つ目では、実験の一時ディレクトリから初回の本文を削除し、etag.txt だけ残して条件付きリクエストを送りました。それでも304で、本文を補うデータは来ません。サーバーは要求内の検証条件を処理し、私のローカルファイルシステムは調べません。
問題はサーバーがダウンロードを拒否したことではなく、有効なタグを持つクライアントが再利用を完了できないことです。短い文字列から元の2,906バイトは復元できません。タグを永続保存し、対応する本文を失えば、不完全な状態が残ります。
自分でダウンロードキャッシュを管理するなら、保存内容と検証情報を対応させます。適用可能な内容があるときに条件を送り、本文がなければ、この例では条件を付けないGETで再取得できます。304の後で紛失に気づいた場合も完全な取得が必要で、今回本文を取得した成功としては扱えません。
RFC 9111は304を受けたキャッシュが更新対象として適切な保存済み応答を選び、関連ヘッダーを更新すると定めます。既存の項目を更新・再利用する手続きで、タグから新しい本文を生成するものではありません。RFC 9111:検証後の保存済み応答の更新
この対応付けと分岐は実装上の提案です。添付スクリプトは五種類のリクエストを行う実験用で、すべてのHTTPキャッシュ規則に対応するダウンローダーではありません。同名のファイルがあるだけで、この要求に使える保存済み応答を見つけたとは扱えません。
ETagの比較は、単純なファイル指紋の比較ではない
ETagはサーバーが生成します。クライアントは二重引用符と、存在する場合の W/ を含む構文を保ちます。生成法は統一されておらず、16進数のように見えるだけで完全な本文の暗号学的ハッシュと決められません。MDN:ETagの生成と構文
四つ目では現在のタグを W/"6accd07a-b5a" として送っても304でした。If-None-Match は弱い比較を使い、強弱を示す接頭辞で同じタグ値がこの条件で不一致になるわけではありません。MDN:If-None-Matchの弱い比較
今回確認したのはこの比較です。バイトが違う二つの本文へ同じ弱いタグを付けて比べておらず、範囲取得の再開や書き込み競合も試していません。計算したSHA-256は別に行ったローカルの内容比較で、サーバーのETagと同じ測定ではありません。
304が保存した版を使えると確認するとき、選択された表現、比較規則、要求条件と合わせて理解します。現在の交換で再転送が必要かという答えであり、要求の文脈から独立した永久の内容証明書ではありません。
保存、利用できる期間、検証の時点を分ける
ETagはキャッシュ寿命を指定しません。Cache-Control などの指示が保存と再利用の方針に関わります。no-cache は通常の再利用経路で検証を要求し、no-store は保存を禁止するための指示です。名前だけで同じスイッチにはできません。MDN:キャッシュの新鮮度と検証
初回の応答には Cache-Control がありませんでした。それだけでブラウザーが絶対に保存しないとは言えず、条件によりヒューリスティックなキャッシュがあり得ます。スクリプトは毎回能動的に要求条件を送り、ブラウザーがいつ手元の内容を直接使うか、特定のブラウザーがファイルをどれほど保持するかは測定していません。
適切な項目の選択には、表現の違いも関わります。Vary は、エンコーディングのネゴシエーションなど、メソッドとURL以外のどの要求フィールドが応答の選択に影響するかを示します。同じURLで受け取った任意の本文を、後のすべての要求へ使えるわけではありません。MDN:Varyとキャッシュ項目の選択
具体的な差も見つかりました。200には Vary: Accept-Encoding があり、三つの304にはVaryがありませんでした。 RFC 9110は、同じ要求の200で生成するこのフィールドを304でも生成するよう要求します。実際のヘッダーは添付に残しています。エンコーディングの切り替えや複数の表現のキャッシュは調べていないため、基本の304の対照だけで、サービス全体のキャッシュ適合性が確認できたとは言えません。
自分の取得処理を調べるために使う
完全な実験パッケージを展開して実行できます。
python3 http304-lab.py http304-output
スクリプト、実行説明、今回の結果も個別に取得できます。Python標準ライブラリとcurlだけを使い、GETを順に五回送ります。本文の削除はスクリプト自身の一時ディレクトリ内だけです。JSONには実際の応答ヘッダー、ステータス、ファイルの有無、バイト数、ローカルハッシュを記録します。
調査では最初に、観察対象がGETで、curl -I のHEADではないと確かめます。HEAD自体が内容を転送しないため、200でも本文ゼロを304の効果の検証には使えません。RFC 9110:HEAD
続いて送った条件、対応する使用可能な本文の有無、アプリの304分岐でそれを保持するかを調べます。最後にキャッシュ方針と表現選択を確認します。今回ネットワークが転送したものと、最終的にアプリが使用するものを分ける順序で、ステータス一つを見て調査を終えることを避けられます。
ログに成功とだけ残すと、新しい本文と再利用の許可を区別できません。ファイルサイズだけでも、要求条件や出力名を見落とします。同じリクエストに対応付けてこれらを記録し、保存内容も調べると、今回新しいファイルが作られなかった理由と、次に読むべきデータを説明できます。今回の対照からの記録上の提案です。
最初の問いへの答えは、304の後の内容は、以前保存し、この要求に適用できる応答から来るのであり、今回の空本文から来るのではないということです。その項目を適切に探し、更新し、再利用するクライアントがキャッシュ交換を完了します。タグだけを残すツールには、本文と両者の関係の管理も必要です。