ByteScope

304

HTTP 304 Not Modified 怎麼修

3xx 轉址IETF 標準RFC 9110

你手上那份還是好的:你在請求裡帶了驗證器,伺服器拿去跟自己那份比對,一樣,於是它刻意只回標頭、不回內容。

為什麼會看到 304,以及該怎麼處理

同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。

用 curl 重現 304

對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。

curl
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.css

第一個請求會把回應帶的 `ETag` 存進一個檔案;第二個把它當成 `If-None-Match` 送回去,而這正是瀏覽器快取在重新驗證時做的事,所以這是一個真的 304,不是模擬出來的。`-D -` 會把第二個回應的標頭印出來,而狀態行就是全部的答案:`304` 代表 tag 對上了。從裡面讀四件事。第一個回應到底有沒有 `ETag`——沒有 tag、也沒有 `Last-Modified`,代表這個網址上的東西永遠無法重新驗證,這就足以解釋一個每次造訪都把自己重抓一遍的網站。這個 304 有沒有照 RFC 9110 的要求把 tag 再送一次?`W/` 前綴是不是只有在協商出壓縮時才出現——把第二行指令分別加上和不加 `-H 'Accept-Encoding: gzip'` 各跑一次,如果一邊拿到 304、另一邊拿到 200,代表驗證器是逐編碼的,而快取必須用 `Vary: Accept-Encoding` 去做鍵。還有,是哪一層回答的:`server:` 和有沒有 `cf-ray` 會告訴你這個 304 是邊緣節點拿自己那份回的,還是你的來源機回的。想走日期那條路就改用 `-z`,curl 的文件裡它叫 `--time-cond`:它會用你指定的日期、或某個本機檔案的時間戳,組出一個 `If-Modified-Since`,而在前面加一個減號會把它反過來變成 `If-Unmodified-Since`。

304 實際上在講什麼

RFC 9110 §15.4.5 把 304 定義成:一個條件式 GET 或 HEAD 的答案——如果那個前置條件沒有算成假,它本來會回 200。這一節裡有兩條規則在除錯時特別重要。304 不能帶內容,它在標頭欄位後的第一個空行就結束了,所以中介層如果在上面多寫了一個 body,那不只是浪費幾個位元組,而是把訊息的框架直接弄壞。另外,304 必須把「200 本來會帶、而快取需要的」那些標頭欄位再送一次,RFC 9110 點名的是 `Content-Location`、`Date`、`ETag` 和 `Vary`——所以一個身上沒有 `ETag` 的 304,本身就很可疑了。條件本身出自 §13:`If-None-Match` 用弱比較函式去比 entity tag,`If-Modified-Since` 比的是一個 HTTP-date,而 §13.2.2 訂了優先順序——只有在 `If-None-Match` 不存在時才會去看 `If-Modified-Since`,也就是說一個兩者都帶的請求,完全是由 tag 決定的,你的 `Last-Modified` 根本沒有上場。這兩種驗證器的鋒利程度也不一樣:HTTP-date 的解析度只有一秒(§5.6.7),所以同一秒內的兩次修改是分不出來的;而 entity tag 是來源機說了算,只要開頭沒有 `W/` 就是強驗證器。

304 最常被搞混的幾個代碼

容易搞混的怎麼分
200網路面板裡的 304 和你 JavaScript 裡的 200,常常是同一次交換。瀏覽器的 HTTP 快取送出條件式請求、收到 304,然後拿存起來的那個 200 去解決你的 `fetch()`——所以你程式碼觀察到的狀態碼是 200,即使那些位元組根本沒過網路。在應用程式碼裡看到 304,代表那個條件式標頭是你自己送的。
412同樣一次前置條件失敗,會因為你想做什麼而回出不同的代碼。GET 上驗證器對不上是 304,因為你手上已經有一份可以用的;換成會改變狀態的方法——PUT 上的 `If-Match` 輸掉了競爭——就是 412(Precondition Failed,RFC 9110 §15.5.13),因為沒有東西可以重用,而且那個寫入絕對不能繼續。
no-cache`no-cache` 正是「製造」304 流量的那個:它允許存起來,但要求重用之前必須重新驗證,所以每個請求都會問,而多數答案是 304。`no-store` 則是連留下這個回應都不准,於是永遠沒有驗證器可送,也永遠不會收到 304。為了修一個「內容太舊」的 bug 就抓 `no-store` 來用,等於把那份舊資料和便宜的重新驗證一起丟掉。
ETag強弱是真的有差,而且只有某些欄位在意。`If-None-Match` 用的是弱比較函式,所以 `W/"v1"` 和 `"v1"` 算對上,被 gzip 弱化過的 tag 照樣回得出 304。`If-Range` 和 `If-Match` 用的是強比較,所以同一個被弱化的 tag 會安靜地讓範圍請求失效——這就是為什麼在一台會 gzip 的伺服器上,大檔案斷線之後會從頭重抓。
原因片語
Not Modified
類別
3xx 轉址
定義出處
RFC 9110
標準
IETF 標準

站上可以搭配的工具

這些全都在你的瀏覽器裡跑,不會上傳任何東西。

相關狀態碼

查 304 的時候,通常也會順手看一下這幾個。

常見問題

怎麼知道這個狀態碼是哪一台伺服器回的?

去讀回應標頭,不要讀那個網頁。curl -sS -o /dev/null -D - https://example.com/path 會只印標頭、不印內容,而 serverviacf-ray 三個加起來,就能指出是哪一層回答的。有 cf-ray 的值代表這個回應是 Cloudflare 處理的,那個值也是他們客服會跟你要的編號。想把邊緣節點整個排除掉,就加上 --resolve example.com:443:203.0.113.10 再打一次:它會照樣送出原本的主機名稱和 SNI,但直接連到你指定的來源位址——答案變了,就代表邊緣節點和來源機講的不是同一件事。

數字後面那句原因片語(像 Not Found)重要嗎?

不重要,而且絕對不要拿它來做判斷。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 可能會刷兩次卡。對一台已經在失敗的伺服器丟出重試風暴,是短暫事故變成長時間事故最常見的那條路。

我可以讓自己的伺服器改回別的狀態碼嗎?

可以,唯一要守的規則是那個代碼必須是真的。所有會自動讀你回應的東西——搜尋引擎、監控、快取、用戶端的重試邏輯——都只看那個數字,從來不看那一頁寫了什麼。用 200 回一頁錯誤訊息,會把故障從你自己的警報裡藏起來;用 200 回一頁不存在的內容,會讓錯誤被當成內容收進索引;而對一個只是格式不對的請求回 500,會把值班的人送去堆疊裡錯的那一半。

304 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。