304
手元のコピーはまだ有効です。リクエストに検証子を添えて送り、サーバーが自分の持っているものと比べ、一致したので、ヘッダーだけを返してボディを意図的に省いた——それが 304 です。
同じ 3 桁でも、リクエストのどちら側にいるかで別々の問題になります。自分に当てはまるブロックを読んでください。
これが出ている理由
まず知っておくべきは、304 はたいていあなたのコードまで届かないということです。ブラウザが自分の判断でキャッシュ済みの応答を再検証したとき、その 304 は HTTP キャッシュが消費してしまい、`fetch()` に渡されるのは保存済みの応答、つまり 200 です。だからアプリケーションのコードで `res.status === 304` になったのなら、条件付きヘッダーを送ったのはキャッシュではなく*あなた*です。ネットワークパネルのほうには本物の 304 の行が出ます。ボディは数百バイト、Size 列はキャッシュの手柄として表示されます。ここに真逆の 2 つの不満が集まります。1 つ目、ファイルを変えてリロードしたのに古いほうが出る——これは 304 が壊れているのではなく、304 が起きていないのです。まだ fresh な応答はリクエストなしで再利用されます。2 つ目、再検証が一度も起きず、リロードのたびに全部を落とし直す——原因はクライアント側ではなく、サーバーに検証子が無いか、安定していないことです。
やること
ハードリロード(Cmd/Ctrl+Shift+R)を使ってください。`Cache-Control: no-cache` が送られ、問い合わせが必ずサーバーまで届きます。パネルを開けている間ずっとそうしたければ DevTools の「Disable cache」です。そのうえで、失敗している行を両方向から読みます。リクエスト側の `If-None-Match` / `If-Modified-Since` がブラウザの覚えていた内容、応答側の `ETag` / `Last-Modified` が次回ブラウザが送る内容です。後者が無ければ、何回リロードしても 304 は永遠に生まれません。キャッシュではなくサーバーを測っているときは `cache: "no-store"` でリクエストして、ブラウザキャッシュに先回りされないようにします。そして配信の形を一度決めきってください。HTML は `no-cache` で必ず再検証、ハッシュ付きアセットは `immutable` で二度と再検証しない。これで先ほどの不満は両方いっぺんに消えます。
これが出ている理由
「うちでは 304 が一度も出ない」というのが定番の報告で、原因はほぼ確実に、バイトが変わっていないのに変わってしまう検証子です。ノードが 2 台以上あると、ファイルの mtime から取った `Last-Modified` はマシンごとに違います。デプロイのたびに別の秒に書き込まれるからです。だからノード B に対して再検証したクライアントは、ノード A からもらったものと決して一致しません。Apache の `FileETag` が 2.3.14 以降に既定としている `MTime Size` にも同じ性質があります。もう半分は圧縮です。nginx は 1.7.3 以降、gzip した応答のエンティティタグを弱く(`W/"..."`)マークします。`If-None-Match` の比較は弱い比較なのでこれ自体は問題ありませんが、その応答は `If-Range` の対象から外れます。そして手前に CDN がいるなら、そもそも `If-None-Match` を転送してくれているのかを確かめてください。ヘッダーが届かないオリジンは、ハンドラがどれほど正しくても 304 を返せません。
やること
検証子には、内容と一緒にしか変わらないものを与えてください。バイト列をハッシュするか、ビル ID を使えば、同じ成果物を配るどのノードも同じタグを作ります。ついでに `Vary` も確認します。`Vary: Accept-Encoding` はキャッシュキーにネゴシエート済みのエンコーディングを含めるので、エントリを作ったときと `Accept-Encoding` が違うクライアントには丸ごと 200 が返り、キャッシュの故障のように見えます。ハンドラが届いたものを本当にパースしているかも確認してください。`If-None-Match` はカンマ区切りで複数のタグを運べますし、どれにも `W/` が付きうるので、生のヘッダー文字列を自分のタグと比較するだけのハンドラは全部取りこぼします。一致したら、同じ `ETag` を付けてボディを一切書かずに 304 を返します。RFC 9110 では 304 は最初の空行で終わるので、ヘッダーのあとに書いたものはクライアントに「次の応答の始まり」として読まれます。
これが出ている理由
304 はエラーではありませんし、そもそも気づかれないことが前提です。ページが速く開くときの静かなほうの半分で、ブラウザが「前に取ってから変わった?」と尋ね、サイトが「変わっていないから手元のものを使って」と答えているだけです。表に出てくる症状はむしろエラーの逆で、サイトが更新されたのは明らかなのに古い内容が出続ける、という形になります。ブラウザが取っておいたコピーがまだ使い回されているからです。
やること
Shift を押しながらリロードするか、Ctrl+Shift+R(Mac なら Cmd+Shift+R)を押してください。手元のコピーを信用せず、サーバーに聞き直すようブラウザに伝わります。それで一度直るのに、しばらくするとまた古い版に戻るなら、ブラウザの設定でそのサイトのキャッシュされたファイルを消してください。いちばん速い切り分けはプライベートウィンドウです。空のキャッシュから始まるので、そこでは正しく、普段のウィンドウでは古いなら、古いコピーはあなたの端末側にあり、サイトには何の問題もありません。
失敗した URL に対してそのまま実行してください。エラーページを表示せずにステータスだけを出すので、ブラウザが描画したものではなくサーバーが言ったことが見えます。
curl -sS --etag-save /tmp/etag -o /dev/null https://example.com/style.css && curl -sS -D - -o /dev/null --etag-compare /tmp/etag https://example.com/style.css1 本目のリクエストは、応答に付いてきた `ETag` をそのままファイルに保存します。2 本目はそれを `If-None-Match` として送り返します。これはブラウザのキャッシュが再検証でやることそのものなので、模擬ではなく本物の 304 です。`-D -` が 2 本目の応答ヘッダーを落とし、ステータス行が答えのすべてです。`304` ならタグが一致したということ。ここから 4 つ読み取ります。1 つ目、そもそも 1 本目の応答に `ETag` があったか。タグも `Last-Modified` も無ければ、その URL は永遠に再検証できません。訪問のたびにサイト全体を落とし直す症状の説明がこれです。2 つ目、304 は RFC 9110 が求めるとおりタグを繰り返しているか。3 つ目、`W/` が付くのは圧縮をネゴシエートしたときだけか。2 本目を `-H 'Accept-Encoding: gzip'` あり/なしで実行して、片方が 304 で片方が 200 なら、検証子はエンコーディングごとに違うということで、キャッシュのキーには `Vary: Accept-Encoding` が要ります。4 つ目、答えたのはどの層か。`server:` と `cf-ray` の有無が、自前のコピーを持つエッジが返した 304 なのか、オリジンが返した 304 なのかを教えます。日付ベースの経路を試すなら `-z` を使います。curl が `--time-cond` として説明しているもので、指定した日付やローカルファイルのタイムスタンプから `If-Modified-Since` を組み立て、先頭にダッシュを付けると `If-Unmodified-Since` に反転します。
RFC 9110 §15.4.5 は 304 を、条件付きの GET または HEAD に対して、前提条件が偽にならなければ 200 を返していたはずの場合の答えと定義しています。デバッグ中に効いてくる規則がこの節に 2 つあります。304 は本文を運べません。ヘッダーフィールドのあとの最初の空行でメッセージが終わると決まっているので、304 にボディを書き足すミドルウェアは、バイトを無駄にしているのではなくメッセージの区切りを壊しています。そして 304 は、キャッシュが必要とする範囲で、200 なら付いていたはずのヘッダーフィールドを繰り返さなければなりません。RFC 9110 はそれを `Content-Location`・`Date`・`ETag`・`Vary` と名指ししているので、`ETag` の付いていない 304 はその時点で怪しいということになります。条件そのものは §13 の話です。`If-None-Match` はエンティティタグを弱い比較関数で比べ、`If-Modified-Since` は HTTP-date を比べます。§13.2.2 が優先順位を決めていて、`If-Modified-Since` が評価されるのは `If-None-Match` が無いときだけです。つまり両方を載せたリクエストの結果はタグだけで決まり、あなたの `Last-Modified` は議論に参加すらしません。この 2 つの検証子は切れ味も同じではありません。HTTP-date の分解能は 1 秒 (§5.6.7) なので、同じ秒に入った 2 回の編集は区別できません。一方エンティティタグはオリジンが決めたものが何であれそれであり、`W/` が付いていない限り強い検証子です。
| 紛らわしいコード | 見分け方 |
|---|---|
200 | ネットワークパネルの 304 と JavaScript から見える 200 は、しばしば同じ 1 往復です。ブラウザの HTTP キャッシュが条件付きリクエストを出し、304 を受け取り、保存済みの 200 で `fetch()` を解決します。だからバイトが線を渡っていなくても、コードが観測するステータスは 200 です。アプリケーションのコードで 304 が見えたなら、その条件付きヘッダーを付けたのはあなた自身です。 |
412 | 同じ「前提条件の不成立」でも、何をしようとしていたかでコードが変わります。GET で検証子が一致しなければ 304 です。使えるコピーをすでに持っているからです。状態を変えるメソッド——PUT に付けた `If-Match` が競争に負けた場合——なら 412 (Precondition Failed, RFC 9110 §15.5.13) です。再利用できるものは無く、書き込みを進めてはいけないからです。 |
no-cache | 304 のトラフィックを*生んでいる*のが `no-cache` です。保存は許すが再利用の前に再検証を要求するので、毎回問い合わせが飛び、その多くが 304 で返ります。`no-store` は保存自体を禁じるので、送る検証子がそもそも無く、受け取る 304 もありません。古い表示を直すつもりで `no-store` に手を伸ばすと、古いコピーと一緒に安上がりな再検証まで捨てることになります。 |
ETag | 強さの違いは実在し、それを気にするフィールドは限られます。`If-None-Match` は弱い比較関数を使うので、`W/"v1"` と `"v1"` は一致し、gzip で弱められたタグでも 304 は出ます。`If-Range` と `If-Match` は強い比較を使うので、同じ弱いタグは範囲リクエストを黙って無効にします。gzip するサーバーで、接続が切れた大きなファイルがゼロから落とし直しになるのはこれが理由です。 |
どれもブラウザ内で完結します。アップロードは発生しません。
304 を調べているとき、ついでに読むことになりがちなコードです。
ページではなく応答ヘッダーを読んでください。curl -sS -o /dev/null -D - https://example.com/path が本文なしでヘッダーだけを出し、server・via・cf-ray の 3 つで、答えたのがどの層かが分かります。cf-ray の値があれば応答を扱ったのは Cloudflare で、その値はサポートが聞いてくる ID です。エッジを完全に外して確かめたいなら、--resolve example.com:443:203.0.113.10 を付けて同じリクエストを投げ直します。元のホスト名と SNI を送りつつ、指定したオリジンのアドレスへ接続するので、答えが変われば、エッジとオリジンの言い分が食い違っているということです。
ありません。分岐条件にしてはいけません。RFC 9112 はクライアントに理由句を無視するよう求めていますし、サーバーは自由に変えられますし、HTTP/2 と HTTP/3 にはそもそも理由句が存在しません。HTTP/1.1 では 404 Not Found として届くものが、HTTP/2 では素の 404 として届きます。このサイトが登録済みの理由句を載せているのは、それが検索される語であり HTTP/1.1 のログに出る文字列だからで、何かのソフトウェアがそれに依存しているからではありません。
5xx と 429 は再試行してよく、4xx はしないでください。次に投げても中身は同じだからです。応答に Retry-After が付いていればそれに従ってください。RFC 9110 がまさにこのために定義していて、値は秒数でも日時でも構いません。付いていなければ、ジッター付きの指数バックオフと明確な上限を使い、しかも冪等なメソッドに限ってください。再試行された POST はカードに二重で課金しかねません。すでに失敗しているサーバーへの再試行の嵐は、短い障害を長い障害に変える典型的な経路です。
変えられます。守るべき規則は 1 つだけ、そのコードが本当であること。応答を自動で読むもの——検索エンジン、監視、キャッシュ、クライアント側の再試行ロジック——はすべて数字だけで判断し、ページの中身は一切見ません。エラーページを 200 で返せば自分の監視から障害が隠れますし、無いページを 200 で返せばエラーが本文として索引されますし、単に不正なリクエストに 500 を返せば、当番の人をスタックの間違った半分へ送り込むことになります。
304 でまだ詰まっているなら、ステータスコード一覧をすべて見る。あるいは上に戻って、自分の立ち位置に向けて書かれたブロックを読んでください。