500
應用程式自己跑起來了,撞到一個它沒預期的東西,然後放棄——所以跟 502、504 不一樣,某個地方一定有一份 stack trace,而整件事的工作就是找出它在哪一份紀錄裡。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
它打中的是某一個端點而不是整個網站,而且通常跟「某一份資料」相關,不是跟「某個時刻」相關:同一頁一直好好的,直到某一筆記錄、某個檔案大小、某個字元出現。回應的 body 是你 API 的錯誤處理器或框架的錯誤頁,不是代理寫的,所以 `res.json()` 有可能順利解析、交給你一個看起來像資料的錯誤物件——也可能在框架整個退回 HTML 的時候丟出 `Unexpected token '<'`。無論哪一種,瀏覽器知道的都不比那三位數字多,因為例外留在伺服器上了。
該怎麼做
要留下的是「造成它的那個請求」,不是那個錯誤:確切的方法、網址、標頭和 body,因為後端會跟你要一個能重現的請求,而你手上這份是唯一的副本。接著從回應標頭裡找關聯 id——`x-request-id`、`x-amzn-trace-id`、`cf-ray`——然後把那串字交出去,因為那是唯一能在幾秒內找到對應伺服器紀錄行的值。解析之前先看 `res.ok` 和 `content-type`,這樣一頁誤闖進來的 HTML 錯誤頁,才不會變成一個離真正原因三層之外的解析器 bug。還有,不要自動重試:500 沒有給你任何方法知道那次寫入到底做了沒,所以一次被重試的訂單可能會變成兩張訂單。如果這個端點非得可重試不可,就送一個冪等鍵,讓伺服器自己去重。
為什麼會看到它
你框架的最後一道處理器接住了一個沒有其他處理器認領的東西,然後在回應離開行程之前把它變成三位數字。例外、堆疊、還有那筆闖禍的輸入,全都在你的紀錄裡,而且一個都不在回應裡——這是對的,因為把它們洩出去,就是 500 變成資訊洩漏的方式。類型不多:某個特定輸入上的未處理例外、某個依賴不回話了(連線池用光、資料庫在復原、憑證過期),或者一個開起來很正常、但缺了某個環境變數的行程——而那個變數它只在某條路徑的第一個請求時才會去讀。
該怎麼做
從關聯 id 開始,讀堆疊裡「第一個」例外,不是最後一個,因為最後一格通常是你自己的錯誤處理器在處理真正的錯誤時又爆掉了。如果應用程式紀錄裡什麼都沒有,那這個 500 就不是應用程式寫的:Apache 錯誤紀錄裡的「Premature end of script headers」指向一支在標頭之前就先印東西的 CGI,或者一次 suexec 的權限拒絕;而 nginx 會為 rewrite 或內部轉址迴圈寫出自己的 500,那是設定的 bug、不是程式碼的 bug。接著用「確切的」那份 payload 去重現,不要用簡化過的,因為觸發 500 的輸入幾乎永遠是那個大家都覺得不可能出現的。如果輸入真的是不合法的,那修正就該是一個 4xx——一個以 500 出現的驗證失敗,代表你的錯誤處理錯了兩次:一次是它掛掉,一次是它把責任推給伺服器。最後,在你假設「一個 500 等於一次失敗的請求」之前,先看 `proxy_next_upstream` 設了什麼:nginx 的 `http_500` 是要自己開的選項,開了之後,一個用戶端請求可以被重播到那一組裡的每一台 upstream 上。
為什麼會看到它
這不是你造成的,你在自己這邊做什麼也改變不了它:是網站自己的程式在組你那一頁的時候出了故障。它通常只影響某一個動作而不是整個網站——搜尋框好好的,結帳卻不行——而且可能是被你這次請求裡某個特定的東西觸發的,例如表單欄位裡一個不常見的字元,或是一個比網站預期還大的檔案。
該怎麼做
重新整理一次,因為真的是暫時性的故障會自己好。如果沒有好,最有用的做法是停止重複同一個動作,尤其那個動作牽涉到付款、訂位或送出資料:伺服器錯誤會把答案藏起來,但不一定會把已經做的事撤銷,所以再按一次按鈕有可能做兩次。重試之前先去看訂單紀錄、收件匣或帳號頁面。回報的時候請附上確切時間、頁面網址,以及你剛剛輸入了什麼——網站維運的人可以用這些找到對應的紀錄,而你打的那個欄位往往就是觸發點。清 cookie、換瀏覽器、重開路由器都不會有幫助,因為故障發生在連線的另一端。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS -o /dev/null -D - -w '\n%{http_code} in %{time_total}s\n' -H 'content-type: application/json' --data '{"quantity":-1}' https://example.com/api/orders`-sS` 會把進度列消掉但留下錯誤訊息,`-o /dev/null` 把 body 丟掉,`-D -` 把回應標頭印到標準輸出,`--data` 送出 payload 並讓這個請求變成 POST,`-w` 則印出狀態碼和實際耗時。先讀標頭那一段,因為它回答了狀態行答不出的那個唯一問題:這是哪一層寫的。`server:` 寫著你的應用程式框架,或是你的技術堆疊會設的任何 `x-request-id`,代表請求有到你的程式碼,而例外就在你紀錄裡那個 id 底下。`server: nginx` 或 `server: cloudflare` 而且完全沒有應用程式的標頭,代表你看到的是應用程式前面那一層的錯誤頁,那三位數字就值得重讀一次——一個拿不到可用答案的中介裝置報的是 502,不是 500。耗時是第二個線索:應用層級的 500 通常回得跟成功一樣快,因為例外是被丟出來的、不是等出來的,所以一個花三十秒的 500 是你程式碼裡的逾時,指向那個卡住的依賴,不是指向丟例外的那一行。把 payload 換成一份你確定合法的,確認這個端點本身是好的;然後把失敗那份留著——那就是重現方式,也是後端從紀錄裡救不回來的東西。
RFC 9110 §15.6.1 把 500 定義成:伺服器遇到一個非預期的狀況,導致它無法完成這個請求;而它所屬的 5xx 類別,§15.6 的引言寫的是伺服器沒有能力執行這個請求。這個定義是整份規格裡刻意最不具體的一個,而那正是有用的地方:500 是一句自承,不是一份診斷,所以這個代碼完全沒有攜帶「什麼壞了」的資訊,只攜帶「是誰發現的」。從這裡可以推出三件事。第一,你的應用程式產生的 500 是一個完全合法的 HTTP 回應,所以前面的代理會原封不動轉出去,不會拿自己的頁面替換掉——這正是為什麼 RFC 9110 §15.6.3 把 502 留給「中介裝置收到不合法回應」的情況,也是為什麼 500 是「你的程式碼有跑到」的證據,而 502 通常代表它根本沒跑。第二,500 不在 RFC 9110 §15.1 那份可依啟發式規則快取的狀態碼清單裡——那份清單只有 200、203、204、206、300、301、308、404、405、410、414 和 501,再無其他——所以沒有中介裝置會自作主張把它存起來,而一個持續出現的 500 是每個請求都被重新產生一次,不是從快取放出來的。第三,500 完全沒有在講「這個請求有沒有產生效果」:規格沒有給用戶端任何方法去知道伺服器在撞上那個非預期狀況之前走到了哪一步,這就是為什麼付款或下單上的 500 不能自動當成可以安全重試。伺服器也會從自己的機制裡吐出 500,不只是應用程式碼——Apache 的 CGI 文件寫著,一個錯誤紀錄寫著「Premature end of script headers」的「Internal Server Error」,代表腳本在它的 HTTP 標頭之前就先印了東西,或者 suexec 的權限檢查拒絕了它;而 nginx 則會為自己內部的故障回 500,例如 rewrite 或內部轉址繞成迴圈。這兩種情況下,三位數字都一樣,而那一行紀錄就是全部的內容。
| 容易搞混的 | 怎麼分 |
|---|---|
| 502 | 500 是應用程式承認自己失敗了,502 是中介裝置在報告應用程式從來沒交出一個可用的答案。RFC 9110 §15.6.3 把 502 定義成「從內部伺服器收到不合法的回應」,而應用程式自己的 500 是一個合法的回應,所以代理會直接原封不動轉出去。實務上是這樣:500 代表有一份 stack trace 可以找,而 502 常常代表工作行程在任何處理器跑起來之前就死了,應用程式紀錄是空的。 |
| 503 | 503 是一個決定,500 是一場意外。RFC 9110 §15.6.4 把 503 定義成伺服器因為過載或排定的維護而暫時無法處理這個請求,並且允許它帶 `Retry-After` 說明要多久。500 裡沒有任何東西在暗示「等一下」,因為它身上沒有任何一件事被期待會過去。如果你的服務是靠丟例外來降載,那它是把一次計畫中的拒絕報告成一次當機,而所有以 503 為判斷依據的用戶端退避邏輯全都會漏掉它。 |
| 400 | RFC 9110 §15.5.1 把 400 定義成:伺服器因為察覺到某種用戶端錯誤而無法處理這個請求。那才是你的端點在拒絕輸入時誠實的代碼。一個在驗證輸入時冒出來的 500,是錯誤處理的 bug、不是伺服器的 bug:這個請求是答得出來的,答案是「不行」,而 5xx 卻在叫用戶端去重試一件永遠不會成功的事。 |
Cloudflare 520 | 透過 Cloudflare 的話,這兩個很好分,而且這個差別值得知道。Cloudflare 的文件把 520 定義成來源機回了一個空的、未知的或非預期的回應——包括標頭超過它 128 KB 上限的情況——所以 520 代表你來源機的答案不是可用的 HTTP。而透過 Cloudflare 抵達的 500 剛好相反:那是你的應用程式刻意產生的一個格式良好的回應,Cloudflare 只是原封不動轉過來。 |
501 | RFC 9110 §15.6.2 把 501 定義成伺服器不支援完成這個請求所需的功能,並指名它是伺服器不認得該方法時該給的答案。跟 500 不一樣,它是在陳述伺服器的能力,不是在陳述某一個請求出了錯;而且它是 RFC 9110 §15.1 列為可依啟發式規則快取的少數錯誤代碼之一——所以一個誤發的 501 可能被快取存起來、在原因消失之後還繼續重播,500 則不會。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 500 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。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,會把值班的人送去堆疊裡錯的那一半。
500 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。