302
一次暫時的繞路:資源還是屬於你要的那個網址,只是現在伺服器想從別的地方把它送出來——而你的瀏覽器會直接繞過去,完全不會讓你的程式碼看到這一跳。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
302 最貴的性質,是你根本看不到它。`fetch()` 預設就跟著轉址走,XHR 連選都不能選,所以一個被彈去 HTML 登入頁的 API 呼叫,會以「成功的 200」收場,只是 body 是一頁網頁;而你實際觀察到的錯誤,是 `res.json()` 丟出來的 `SyntaxError: Unexpected token '<'`——它指著你的解析器,不是指著你過期的 session。第二個症狀是 body 不見了:在 302 上瀏覽器可以把你的 POST 改成 GET 重送,於是伺服器紀錄到一個沒有 payload 的 GET,你送出去的每個欄位都沒了。第三個是跨來源:轉址的目標是一個全新的請求,得自己通過 CORS,所以彈去另一台沒送 `Access-Control-Allow-Origin` 的主機,會被報成「你呼叫的那個網址 CORS 錯誤」,造成這件事的那一跳一點痕跡都沒留下。
該怎麼做
在你解析任何東西之前,先看 `response.redirected`,再把 `response.url` 跟你請求的網址比一比;不一樣就兩個都記下來。`content-type` 也要看,因為你要 JSON 卻收到 HTML,那就是登入彈跳換了個名字。DevTools 裡把「Preserve log」打開,讓轉址那一列不會被它自己觸發的導覽洗掉。如果伺服器是你的,真正的修法在上游:API 遇到過期的 session 應該回 401 加一個 JSON body,不是回一個轉去登入頁的 302,因為只有前者能讓用戶端分辨「請重新登入」和「伺服器丟了一份文件給我」。如果伺服器不是你的,`redirect: "manual"` 會給你一個 opaque-redirect 回應——狀態碼 0、type 是 `"opaqueredirect"`、`Location` 讀不到——這足夠讓你偵測到自己被彈走、跳出登入提示,但永遠不夠告訴你被彈去哪。
為什麼會看到它
不是這個 302 是故意的、但用戶端落錯地方,就是它在繞圈。落錯地方是代理的問題:應用程式是拿「它以為的那個請求」去組絕對網址,所以在終結 TLS 的代理後面它會吐 `http://`,在容器裡它會吐一個內部主機名稱、或一個外面根本不存在的連接埠。nginx 把同一種失敗命名了三次——`absolute_redirect`、`server_name_in_redirect`、`port_in_redirect`——而應用框架則要 `X-Forwarded-Proto` 和 `X-Forwarded-Host` 既被轉進來、又被設成可信任,才有辦法算對。至於迴圈,幾乎都是 cookie:你轉去 `/login`,登入的回應設了一個 session cookie,轉回來的那一跳卻直接又彈回 `/login`,因為那個 cookie 根本沒被存下來——在純 HTTP 上送 `Secure`、`SameSite=None` 卻沒有 `Secure`、`Domain` 對不上,或者轉址跨到了跟設定 cookie 那台不同的主機。
該怎麼做
去讀線路上真正的那個 `Location`,不要讀你原始碼裡的那個;bug 是真的時,兩者剛好就會不一樣。接著檢查代理契約的兩端:代理有沒有送 `X-Forwarded-Proto`?框架有沒有被設定成信任它?迴圈的話,去看登入回應上的 `Set-Cookie`,然後看「下一個」請求到底有沒有帶 `Cookie` 標頭——沒有的話,bug 就在那些屬性上,轉址邏輯再怎麼寫都救不回來。最後,代碼要自己選,不要收下框架的預設值:單純的暫時繞路用 302,POST 之後用 303 讓後續那個請求明訂是 GET,方法和 body 必須原封不動重送的時候用 307。
為什麼會看到它
這個網站是故意把你送去別的地方的,而且它的意思是暫時的——登入頁、某個國家或語言的版本、維護公告。網址列變成你沒打過的東西,是轉址在運作,不是被綁架。會出問題的樣子是彈跳:你登入了,卻又回到登入頁,然後一直繞,或者瀏覽器停下來說「重新導向次數過多」。那是網站的 session 處理,而最常見的兇手是一個你的瀏覽器沒留住的 cookie。
該怎麼做
如果你一直被丟回登入頁,就把那個網站的 cookie 允許起來——這種迴圈幾乎都代表登入用的 cookie 被擋掉、被清掉或被丟掉了。看看有沒有哪個擴充功能會清 cookie,然後在無痕視窗裡、把該站 cookie 打開再試一次;在那裡可以,差別就在某個擴充功能或某個針對這個網站的 cookie 設定。如果你是被轉到一個你不想要的國家或語言版本,請用你落地那一頁上的語言連結去換,不要去改網址,因為下一個請求那條轉址還是會再觸發一次。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS -D - -o /dev/null -L -d 'user=me' -w '\nfinal %{http_code} after %{num_redirects} hops at %{url_effective}\n' https://example.com/login`-d` 會送出一個真的請求 body,這樣本身就是 POST——這裡刻意不加 `-X`,因為 curl 文件寫明它會套在整條鏈的每一個請求上,正好會蓋掉這行指令想讓你看到的那次方法改寫。`-L` 跟著整條鏈走,`-D -` 把每一跳的標頭都印出來,`-w` 則印出最後的狀態碼、跳了幾次,以及回答的那個網址。這份輸出裡有四樣東西決定你下一步。第一條狀態行把 302 跟 303、307 分開,而那是「方法可能會變」、「後續請求就是 GET」、「原封不動重送」三種契約的差別。`location:` 的值告訴你應用程式是不是拿對的請求資訊去組網址——HTTPS 網站上出現 `http://`、內部主機名稱,或一個只有容器裡才存在的連接埠,三者都指向少了 `X-Forwarded-Proto` 或 `X-Forwarded-Host`。同一個回應上的 `set-cookie:` 則是登入迴圈的判決點:在純 HTTP 上送 `Secure`、`SameSite=None` 沒配 `Secure`,或者 `Domain` 跟目的地不同源,都會讓下一跳帶著空的 session 過去,然後再彈一次。至於最後那個回應的 `content-type`,就是你前端看不到的那個關鍵:你要 JSON 卻拿到 `text/html`,那就是登入彈跳。curl 的文件寫著,它跟著 301、302 或 303 走的時候會把 POST 降成 GET,其他 3xx 則保留方法——所以同一行加上 `--post302` 再跑一次,比對伺服器紀錄,就能證明弄丟你欄位的是不是那次方法改寫。
RFC 9110 §15.4.3 把 302 定義成:目標資源暫時待在另一個 URI 底下;接著補了一句讓它和 301 分家的指示——因為這個轉址偶爾會變,用戶端之後仍然應該繼續用原本的目標 URI。同一節也帶著那個歷史包袱:用戶端「可以」把方法從 POST 改成 GET,而 307(Temporary Redirect)被指名為你不想要那樣時該用的代碼。規格沒說的部分一樣有用:302 不在 RFC 9110 §15.1 那份可依啟發式規則快取的狀態碼清單裡,所以除非你自己掛上 `Cache-Control`,302 不會被存起來,每個請求都會重問一次。這個性質,正是它適合拿來反覆調整的原因。在你動手寫登入流程之前,還有一個區別要先分清楚:§15.4.4 把 303(See Other)定義成「伺服器正把用戶端導向另一個資源,由 `Location` 欄位裡的 URI 指出,用意是對原始請求提供一個間接的回應」,而且明訂後續那個請求要用 GET。多數框架在 post-redirect-get 的時候吐的是 302,但真正表達那個意思的代碼是 303。
| 容易搞混的 | 怎麼分 |
|---|---|
307 | 307(Temporary Redirect)就是保證方法不變的 302。RFC 9110 §15.4.3 基於歷史因素,允許用戶端在 302 上把 POST 變成 GET;§15.4.8 定義 307,用意就是讓方法和 body 原封不動重送。一樣是暫時的,契約不一樣——而如果你的轉址剛好夾在表單送出的中間,那份契約就是你的請求 body。 |
303 | 303(See Other,§15.4.4)的意思是「你這個請求的答案在另一個 URI 上」,而且後續那一次是 GET。這正是 post-redirect-get 真正在講的事,而且它是明確的,302 只是「允許」同樣的行為。POST 成功之後要轉址,就用 303 把意圖寫清楚,不要靠一條歷史包袱來的允許條款。 |
| 301 | 使用者看得到的差別是永不永久,實務上的差別是快不快取。302 不在 RFC 9110 §15.1 那份可依啟發式規則快取的狀態碼清單裡,所以沒帶明確 `Cache-Control` 就永遠不會被存起來,你下次部署想改目的地就能改。301 會被存起來,而且沒有任何辦法把它從訪客的瀏覽器裡清掉。先用 302 反覆調,確定了再升級。 |
| 401 | 一個用「轉去登入頁的 302」回答過期 session 的 API,會把每一次認證失敗都變成一個裝滿 HTML 的 200,因為瀏覽器在你的程式碼看到任何東西之前就先跟著轉址走了。改成 401 加一個 JSON body——以及照 RFC 9110 §15.5.2 附上 `WWW-Authenticate` 標頭——才能讓必須處理它的那個用戶端讀得懂這次失敗。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 302 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。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,會把值班的人送去堆疊裡錯的那一半。
302 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。