以下行為只在 Node.js v24.21.0(內建 fetch,底層是 undici)+ Express 5.2.1 上實測過。
症狀
在一個 REST 範例裡,用 fetch 手動帶上次拿到的 ETag(If-None-Match)再查訂單,預期回 304,實際回 200 並整份重送。
原因
兩邊各做了一件「合理」的事,疊起來就壞了:
fetch這邊:看到請求自帶 conditional header,就自動補上Pragma: no-cache與Cache-Control: no-cache。我先用一支小探測程式把伺服器實際收到的 headers 印出來,看到多出的pragma: no-cache+cache-control: no-cache才確定根因- Express 這邊:
req.fresh交給fresh模組判斷,而fresh(2.0.0)看到Cache-Control含no-cache一律當作過期(index.js43–47 行,註解寫的是為了支援 end-to-end reload)→ 不比對 ETag,直接整份回 200
fetch 的行為是規格寫好的,不是 undici 的怪癖。WHATWG Fetch 規格:
- cache mode 的定義後面有一句:header list 含
If-Modified-Since、If-None-Match、If-Unmodified-Since、If-Match或If-Range時,如果 cache mode 是"default",fetch 會把它改成"no-store" - HTTP-network-or-cache fetch 的步驟:cache mode 是
"no-store"或"reload"時,header 裡沒有Pragma就補Pragma: no-cache、沒有Cache-Control就補Cache-Control: no-cache;cache mode 是"no-cache"且沒有Cache-Control時,補的是Cache-Control: max-age=0
(未查證:瀏覽器的 fetch 實際送出的 headers 是否完全一樣。規格是同一份,但我只在 Node/undici 上量過。)
修法
手動帶 If-None-Match 時,同時指定 cache: 'no-cache':
// ✗ 回 200:fetch 自動補 Cache-Control: no-cache
await fetch(url, { headers: { 'if-none-match': etag } });
// ✓ 回 304:fetch 改送 Cache-Control: max-age=0(意思是「先跟伺服器確認」)
await fetch(url, { headers: { 'if-none-match': etag }, cache: 'no-cache' });為什麼這樣解
cache: 'no-cache' 在 fetch 的語意是「可以用快取,但要先跟伺服器確認」,送出的是 max-age=0,fresh 不會擋,ETag 比對才會發生。實測加上之後,伺服器收到的是 cache-control: max-age=0,同一個 ETag 回 304。
診斷順序值得記:沒有直接猜修法,而是先寫一支小探測程式,把伺服器實際收到的 headers 印出來,看到多出的 cache-control: no-cache 才確定根因。
怎麼預防再次發生
- 寫「帶 ETag 再查」的客戶端(或測試)時,一定要 assert 第二次是 304 且 body 0 bytes,而不是只看「有拿到資料」——回 200 也有資料,功能上看不出壞掉,只是快取靜默失效
- 把這個坑寫成回歸測試(同一個 ETag:fetch 預設值回 200、加
cache: 'no-cache'回 304),升級 Node 或 Express 時就會知道行為有沒有變
相關主題
- 「HTTP 快取現成」是選 REST 的理由之一,但現成也要用對
- gRPC 錯誤細節放中文導致錯誤變 UNKNOWN(同一個練習專案裡踩的另一個坑)