以下行為只在 Node.js v24.21.0(內建 fetch,底層是 undici)+ Express 5.2.1 上實測過。

症狀

在一個 REST 範例裡,用 fetch 手動帶上次拿到的 ETag(If-None-Match)再查訂單,預期回 304,實際回 200 並整份重送。

原因

兩邊各做了一件「合理」的事,疊起來就壞了:

  1. fetch 這邊:看到請求自帶 conditional header,就自動補上 Pragma: no-cache 與 Cache-Control: no-cache。我先用一支小探測程式把伺服器實際收到的 headers 印出來,看到多出的 pragma: no-cache+cache-control: no-cache 才確定根因
  2. Express 這邊:req.fresh 交給 fresh 模組判斷,而 fresh(2.0.0)看到 Cache-Control 含 no-cache 一律當作過期(index.js 43–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(同一個練習專案裡踩的另一個坑)