301
舊網址退休了,接手的是 `Location` 裡的那一個——而 301 是少數幾種伺服器沒交代、快取也可以自己存起來的回應,所以送錯的 301 是最難收回來的一種轉址。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
你大概不會親眼看到那個 301,因為 `fetch()` 預設就是 `redirect: "follow"`、XHR 更是沒得選,所以你的程式碼拿到的是「另一個」網址回來的回應,只是 `response.redirected === true`、`response.url` 不是你當初要的那個。這後面藏著兩件事。你的 POST 有可能被改成 GET 重送——RFC 9110 在 301 上允許的正是這件事——於是伺服器紀錄裡是一個沒有 body 的 GET,你的處理器看起來像壞了,其實是根本沒被呼叫到。另外,跨來源的轉址會開一個全新的請求,那個請求得自己通過 CORS,所以轉去一台沒送 `Access-Control-Allow-Origin` 的主機,瀏覽器會把它報成「原本那個網址的 CORS 失敗」,中間那一跳整個被藏起來。再加上瀏覽器會把轉址快取起來,所以你一旦載入過錯的那一版,就算伺服器修好了,你這台機器還是會繼續走錯的那條。
該怎麼做
在你相信那份資料之前,先讀 `response.redirected` 和 `response.url`,然後把兩者的差別記到紀錄裡——這一行就能把一個謎團變成一條你可以去刪掉的轉址規則。在 DevTools 裡把「Disable cache」勾起來、把「Preserve log」打開,因為導覽層級的轉址會被緊接著的那次導覽從面板上抹掉。想知道那條轉址到底還在不在,就拿同一個網址跑一次 curl:curl 沒有快取,所以瀏覽器會轉、curl 不轉,那份轉址就是存在你機器上,不在伺服器上。API 呼叫裡不要依賴轉址——直接把用戶端指向正規的網址,把那趟來回花在請求上,不要花在繞路上。在瀏覽器裡用 `redirect: "manual"` 除錯效果很差:它給你的是一個 opaque-redirect 回應,狀態碼 0、`Location` 讀不到,只夠讓你知道「有轉址」,永遠不會告訴你轉去哪。
為什麼會看到它
不是你送了一個現在後悔的 301,就是轉址還在但指錯地方。指錯地方那一類幾乎都是代理造成的:你的應用程式是拿「它以為的那個請求」去組絕對網址,而在一台負責終結 TLS 的代理後面,它會以為協定是 `http`、主機名稱是某個內部名字。nginx 把同一類 bug 拆成三個開關——`absolute_redirect`、`server_name_in_redirect`、`port_in_redirect`——而轉址網址結尾多一個 `:8080`,就是最後那個。迴圈那一類有一個很有名的原因,值得在別的都還沒查之前先確認:Cloudflare 自己的文件寫著,Flexible 加密模式配上一台會把 HTTP 轉去 HTTPS 的來源機,會產生無限轉址迴圈,因為來源機永遠只看得到純 HTTP,於是它一直對一個「已經是 HTTPS」的請求說「去 HTTPS」。
該怎麼做
還在猶豫的階段就用 302 轉,等目的地確定了再升級成 301,因為 302 預設不可快取、301 可以。真的要送 301 的時候,一起送一個明確的 `Cache-Control: max-age=`,讓影響範圍有一個到期日,而不是交給啟發式規則去猜。在代理後面,要把 `X-Forwarded-Proto` 和 `X-Forwarded-Host` 轉進來而且設成可信任,再確認框架以為自己在回的是哪個協定;或者乾脆把絕對網址整個拿掉,改用相對的 `Location`(`absolute_redirect off;`)。想收回一個已經放出去的 301,就再發一條反方向的轉址——但要對自己誠實,這只買到一件事:它只對「存起來的那份已經過期、或從來沒存過」的用戶端有效,而躺在別人瀏覽器裡的那一份,沒有任何清除機制。
為什麼會看到它
這個網站把頁面搬走了,而它正在叫你的瀏覽器去新地址,所以網址列才會變成你沒打過的東西。這是正常的。會變不正常的有兩種樣子:網址跳到一個明顯不對的地方,或者瀏覽器直接放棄、跳出「重新導向次數過多」,那是兩條規則指著彼此。這兩種都是網站的設定問題,不是你的裝置——而錯的那條之所以在網站修好之後還陰魂不散,是因為永久轉址正是瀏覽器被允許記住、而且可以不問就照做的那一種。
該怎麼做
先用無痕視窗開同一個網址。無痕視窗一開始沒有那份記住的轉址,所以在那裡打得開,就代表過期的規則在你這台機器上,去瀏覽器設定裡把那個網站的快取檔案清掉就會不見。Chrome 的設定在「隱私權和安全性」底下,要清的是「快取圖片和檔案」,存起來的轉址就是在那裡。如果無痕視窗一樣在繞圈,那你在本機做什麼都沒用——迴圈在網站那邊,你能提供給他們最有用的東西,是你一開始輸入的網址,和它最後停在哪個網址。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS -D - -o /dev/null -L -w '\n%{num_redirects} hops, final %{http_code} at %{url_effective}\n' http://example.com/old-page`-D -` 會把「每一跳」的回應標頭都印到標準輸出,不是只印最後一跳,`-o /dev/null` 把每個 body 丟掉,`-L` 跟著整條鏈走,`-w` 則印出總共跳了幾次、最後的狀態碼,以及回出那個狀態的網址。從上往下讀。每一條狀態行告訴你那一跳是 301 還是 302,而這決定了快取有沒有資格把它留著。每一個 `location:` 告訴你目標長什麼樣——一個 HTTPS 網站上出現絕對的 `http://`、而它又會轉回 HTTPS,那就是迴圈;出現內部主機名稱或多一個 `:8080`,那就是代理告訴應用程式錯的請求資訊。而在 301 那一跳上,找 `cache-control` 或 `expires`:兩個都沒有的話,這個回應照 RFC 9110 §15.1 就是可依啟發式規則快取的,而那一版轉址就是你的訪客會一直留著的那一版。還有兩招能讓結論變得明確。curl 沒有快取,所以瀏覽器會轉、這行指令不轉,那條轉址就只存在你這台機器上。另外 curl 自己的文件寫著,跟著 301、302 或 303 走的時候它會把 POST 降成 GET——所以同一行加上 `--post301` 再跑一次,比對伺服器紀錄,就能證明吃掉你請求 body 的是不是這條轉址。
RFC 9110 §15.4.2 把 301 定義成:目標資源已經被指派了一個新的永久 URI,伺服器要產生一個 `Location` 欄位帶上那個首選的網址;而且它明白允許用戶端把自己手上指向舊 URI 的參照全部改寫掉。這個定義裡有三件事最常絆倒人。第一,「永久」是講給快取聽的承諾,不是你設定檔裡的一句註記:RFC 9110 §15.1 把 301 列在可依啟發式規則快取的狀態碼裡,所以一個完全沒帶 `Cache-Control` 的 301 照樣會被存起來、被重複使用——CDN 會、代理會、瀏覽器自己也會——而且之後不會再回頭問你一次。第二,方法不保證撐得過去:§15.4.2 寫著,基於歷史因素,用戶端「可以」在後續那個請求把方法從 POST 改成 GET,並且指名 308(RFC 7538)才是你不想要這種行為時該用的代碼。第三,`Location` 是一個 URI-reference,RFC 9110 §10.2.2 允許它是相對的、以目標 URI 為基準去解——所以一條在設定檔裡看起來沒問題的轉址,只要產生它的那個東西被餵了錯的請求資訊,還是可以把人送到錯的協定、錯的主機或錯的連接埠上。規格裡沒有任何機制讓你把一個已經被存在別人那裡的 301 收回來,而這一件事,就足以決定你今天到底要不要送出它。
| 容易搞混的 | 怎麼分 |
|---|---|
308 | 308(RFC 7538)是保證方法和 body 原封不動重送的永久轉址,而這正是 RFC 9110 §15.4.2 在 301 上不肯給的保證——它允許用戶端把 POST 變成 GET。兩者預設都可快取,所以 308 一樣黏、一樣收不回來;選哪一個是在選方法要不要保住,不是在選轉址能活多久。 |
| 302 | 看得見的差別是「永久」,會花到錢的差別是快取。301 在 RFC 9110 §15.1 那份可依啟發式規則快取的清單裡,302 不在,所以沒帶明確 `Cache-Control` 的 302 不會被存起來,下一個請求你想改目的地就能改。這就是「先用 302 轉,確定了再升級」這個做法的全部理由。 |
HSTS | 不是每一次從 `http://` 跳到 `https://` 都是你的伺服器在轉。一台主機只要送過 `Strict-Transport-Security`,瀏覽器就會在任何請求離開這台機器之前先把網址改寫掉——Chrome 的網路面板會把它顯示成 `307 Internal Redirect`,附帶 `Non-Authoritative-Reason: HSTS`。它在伺服器那邊沒有一條對應的規則可以刪,所以你翻遍設定檔也找不到那條轉址。 |
Cloudflare | 轉址有可能在邊緣節點就發出去了,你的來源機根本沒被問到——Cloudflare 的 Always Use HTTPS、Bulk Redirects 和 Redirect Rules 都會產生這種轉址——所以一條在你伺服器設定裡完全找不到的 301,還是可能是真的。相對應的陷阱在另一個方向:Cloudflare 的文件寫著,Flexible 加密模式加上一台會把 HTTP 轉去 HTTPS 的來源機就是無限迴圈,因為來源機永遠看不到它要求的那個 HTTPS。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 301 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。curl -sS -o /dev/null -D - https://example.com/path 會只印標頭、不印內容,而 server、via 和 cf-ray 三個加起來,就能指出是哪一層回答的。有 cf-ray 的值代表這個回應是 Cloudflare 處理的,那個值也是他們客服會跟你要的編號。想把邊緣節點整個排除掉,就加上 --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 可能會刷兩次卡。對一台已經在失敗的伺服器丟出重試風暴,是短暫事故變成長時間事故最常見的那條路。
可以,唯一要守的規則是那個代碼必須是真的。所有會自動讀你回應的東西——搜尋引擎、監控、快取、用戶端的重試邏輯——都只看那個數字,從來不看那一頁寫了什麼。用 200 回一頁錯誤訊息,會把故障從你自己的警報裡藏起來;用 200 回一頁不存在的內容,會讓錯誤被當成內容收進索引;而對一個只是格式不對的請求回 500,會把值班的人送去堆疊裡錯的那一半。
301 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。