HTTPキャッシュと304応答
価格120の応答を保存し、経過時間と配信元の価格を変えて再取得します。cache.pyの--atは初回からの経過秒数です。鮮度の期限、ETagと更新日時による再検証、no-cacheとno-storeの違いを、要求回数と本文の変化で確認します。
説明
HTTPキャッシュは応答を保存し、条件の合う要求に再利用します。保存できるか、配信元へ確認せず使えるか、古くなった本文をどう更新するかは、それぞれ別の判断です。304応答には本文がないため、保存済み本文との組合せが必要になります。
価格120の応答を保存し、経過時間と配信元の価格を変えて再取得します。cache.pyの--atは初回からの経過秒数です。鮮度の期限、ETagと更新日時による再検証、no-cacheとno-storeの違いを、要求回数と本文の変化で確認します。
1配信元の保存条件と識別子
最初にcurlで価格の応答ヘッダーを表示します。
curl -sS -i保存条件と本文を取得 http://127.0.0.1:18080/price価格の資源Cache-Control: public, max-age=60は共有キャッシュでの保存を許し、鮮度寿命を60秒としています。ETagの"v1"は表現の識別子、Last-Modifiedは更新日時です。ETagは必ずハッシュや連番になるわけではなく、配信元が選ぶ不透明な値です。
2時刻0でキャッシュへ保存
cache.pyはGET応答を保存するクライアントです。初期状態には保存済み応答がありません。
python3 cache.py getキャッシュを使って取得 http://127.0.0.1:18080/price --at 0受信時刻を0秒とする配信元から200と本文price=120を受け取り、識別子と一緒に保存します。この演習の時刻計算は受信時のDateと指定時刻が一致し、初期Ageと通信遅延が0という条件です。
3期限内は配信元へ要求しない
59秒時点で同じURLを取得し、配信元が受け取った要求の累計も表示します。
python3 cache.py get http://127.0.0.1:18080/price --at 59期限直前で取得
cat origin-count.txt配信元への要求回数age=59は寿命60より小さいためHITとなり、保存済み本文を返します。累計は最初のcurlと初回保存の2回のままです。キャッシュの再利用では、配信元へ要求しない場合があります。
4期限に達したらETagで再検証
60秒時点で取得します。保存済みのETagを条件付き要求に使います。
python3 cache.py get http://127.0.0.1:18080/price --at 60鮮度の境界で再取得age=60は寿命60未満ではないため再検証します。If-None-Match: "v1"に対して304が返り、転送本文は0バイトです。クライアントは保存していたprice=120を使い、応答の新しい日時情報で鮮度を更新します。
5配信元だけを変更
70秒時点で配信元の価格を150へ変更し、同じ時刻にキャッシュから取得します。
curl -sS -H "X-Lab-Time: 70"配信元の更新時刻 -H "Content-Type: application/json" --data-binary '{"price":150}新しい価格' http://127.0.0.1:18080/change
python3 cache.py get http://127.0.0.1:18080/price --at 70変更直後にキャッシュから取得配信元はprice=150、version=2になりますが、キャッシュはまだprice=120を返します。直前の再検証から10秒しか経っていないためです。配信元で値が変わると、保存済み応答が即座に通知を受けて更新されるわけではありません。X-Lab-Timeはこの配信処理の演習用時計を指定しています。
6変更済みの表現を受け取る
次に鮮度が切れる120秒時点で取得します。
python3 cache.py get http://127.0.0.1:18080/price --at 120再検証後の寿命が尽きた時刻If-None-Matchは保存済みの"v1"ですが、配信元は"v2"です。304ではなく200と新本文price=150が返り、キャッシュを置き換えます。再検証はいつも本文を省く処理ではなく、変更の有無によって本文の転送が決まります。
7304自体には価格本文がない
curlで現在のETagを指定し、304のヘッダーと本文を直接確認します。
curl -sS -i -H 'If-None-Match: "v2"現在の識別子との一致を確認' -H "X-Lab-Time: 120" http://127.0.0.1:18080/price304にはprice=150の本文がありません。先ほどcache.pyが価格を表示できたのは、保持している本文を再利用したためです。304だけを保存しても文書本文の代わりにはなりません。
8更新日時を条件にする
ETagの代わりに、70秒時点のLast-ModifiedをIf-Modified-Sinceへ指定します。
curl -sS -i -H "If-Modified-Since: Mon, 09 Nov 2026 00:01:10 GMT保存した更新日時" -H "X-Lab-Time: 120" http://127.0.0.1:18080/price指定日時以降に変更されていないため304です。HTTP日時の精度は秒単位なので、短時間の複数更新を識別する用途などではETagの方が適切な場合があります。
9二つの条件がある場合
古いETagと、変更なしを示す更新日時を同時に送ります。
curl -sS -i -H 'If-None-Match: "v1"古い識別子' -H "If-Modified-Since: Mon, 09 Nov 2026 00:01:10 GMT現在の更新日時" -H "X-Lab-Time: 120" http://127.0.0.1:18080/priceIf-None-Matchがある場合はIf-Modified-Sinceより優先されます。"v1"は現在の"v2"と異なるため、200とprice=150が返ります。二条件のどちらか一方が一致すれば304になる、という扱いではありません。
10no-cacheは保存後も再検証
no-cacheを返すURLを、1秒違いで二回取得します。
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-cache再利用前の検証を要求" --at 130
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-cache" --at 131直後でも配信元へ確認初回の本文は保存され、二回目はETagで再検証して304になります。no-cacheは保存禁止ではなく、再利用前の検証を求めます。名前の印象だけでno-storeと同じ意味にしないようにします。
11no-storeでは保存しない
no-storeを返すURLを二回取得します。
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-storeこの応答の保存を禁止" --at 140
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-store" --at 141
python3 cache.py show保存されたURLと本文二回とも200と本文を受け取り、store=noになります。保存一覧にno-storeのURLはありません。no-storeはこの応答をキャッシュへ保存しない指示で、別の時点に保存済みのデータを何でも消す命令ではありません。
12Ageと通信遅延を含む経過時間
単に保存からの秒数を数えるだけでは、上流で古くなっている応答を若く見積もります。要求時刻10、受信12、Date=0、現在40として二つのAge値を計算します。
python3 -c 'from cache import current_age; print("Age=5:", current_age(10,12,0,5,40)上流Ageが5秒); print("Age=30:", current_age(10,12,0,30,40)上流Ageが30秒)' 見かけの経過時間は12−0=12、応答遅延は12−10=2です。Age=5ならmax(12,5+2)+(40−12)=40、Age=30ならmax(12,30+2)+28=60です。鮮度はこうして求める現在の経過時間と寿命を比較します。
まとめ
保存可否・鮮度・再検証を分けて考え、配信元への要求と返る本文を対応させます。
304は保存済み本文を再利用するための応答です。変更があれば200と新本文を受け取ります。
no-cacheは再利用前の検証、no-storeは保存禁止を指示します。Ageや通信遅延も鮮度に影響します。
操作履歴と解説を振り返れます。
シェル / curl・HTTP/1.1
curl -sS -i http://127.0.0.1:18080/priceHTTP/1.1 200 OK Date: Mon, 09 Nov 2026 00:00:00 GMT Cache-Control: public, max-age=60 ETag: "v1" Last-Modified: Mon, 09 Nov 2026 00:00:00 GMT Content-Type: text/plain; charset=utf-8 Content-Length: 10 price=120
python3 cache.py get http://127.0.0.1:18080/price --at 0origin=200 transferred-body=10 bytes store=yes Cache-Control: public, max-age=60 price=120
python3 cache.py get http://127.0.0.1:18080/price --at 59
cat origin-count.txtcache age=59 lifetime=60 fresh=True HIT origin-request=no price=120 2
python3 cache.py get http://127.0.0.1:18080/price --at 60cache age=60 lifetime=60 fresh=False If-None-Match: "v1" origin=304 transferred-body=0 bytes reuse stored body store=yes Cache-Control: public, max-age=60 price=120
curl -sS -H "X-Lab-Time: 70" -H "Content-Type: application/json" --data-binary '{"price":150}' http://127.0.0.1:18080/change
python3 cache.py get http://127.0.0.1:18080/price --at 70{"price":150,"version":2}
cache age=10 lifetime=60 fresh=True
HIT origin-request=no
price=120python3 cache.py get http://127.0.0.1:18080/price --at 120cache age=60 lifetime=60 fresh=False If-None-Match: "v1" origin=200 transferred-body=10 bytes store=yes Cache-Control: public, max-age=60 price=150
curl -sS -i -H 'If-None-Match: "v2"' -H "X-Lab-Time: 120" http://127.0.0.1:18080/priceHTTP/1.1 304 Not Modified Date: Mon, 09 Nov 2026 00:02:00 GMT Cache-Control: public, max-age=60 ETag: "v2" Last-Modified: Mon, 09 Nov 2026 00:01:10 GMT Content-Type: text/plain; charset=utf-8
curl -sS -i -H "If-Modified-Since: Mon, 09 Nov 2026 00:01:10 GMT" -H "X-Lab-Time: 120" http://127.0.0.1:18080/priceHTTP/1.1 304 Not Modified Date: Mon, 09 Nov 2026 00:02:00 GMT Cache-Control: public, max-age=60 ETag: "v2" Last-Modified: Mon, 09 Nov 2026 00:01:10 GMT Content-Type: text/plain; charset=utf-8
curl -sS -i -H 'If-None-Match: "v1"' -H "If-Modified-Since: Mon, 09 Nov 2026 00:01:10 GMT" -H "X-Lab-Time: 120" http://127.0.0.1:18080/priceHTTP/1.1 200 OK Date: Mon, 09 Nov 2026 00:02:00 GMT Cache-Control: public, max-age=60 ETag: "v2" Last-Modified: Mon, 09 Nov 2026 00:01:10 GMT Content-Type: text/plain; charset=utf-8 Content-Length: 10 price=150
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-cache" --at 130
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-cache" --at 131origin=200 transferred-body=10 bytes store=yes Cache-Control: no-cache, max-age=60 price=150 cache age=1 lifetime=60 fresh=False If-None-Match: "v2" origin=304 transferred-body=0 bytes reuse stored body store=yes Cache-Control: no-cache, max-age=60 price=150
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-store" --at 140
python3 cache.py get "http://127.0.0.1:18080/price?policy=no-store" --at 141
python3 cache.py showorigin=200 transferred-body=10 bytes
store=no Cache-Control: no-store
price=150
origin=200 transferred-body=10 bytes
store=no Cache-Control: no-store
price=150
{"target": "/price", "vary": [], "values": {}, "saved": 120, "control": "public, max-age=60", "etag": "\"v2\"", "body": "price=150\n"}
{"target": "/price?policy=no-cache", "vary": [], "values": {}, "saved": 131, "control": "no-cache, max-age=60", "etag": "\"v2\"", "body": "price=150\n"}
entries= 2python3 -c 'from cache import current_age; print("Age=5:", current_age(10,12,0,5,40)); print("Age=30:", current_age(10,12,0,30,40))' Age=5: 40 Age=30: 60
演習完了