404
伺服器看懂了你的請求,只是那條路徑上沒有東西可以給你。在現在的架構裡,這比較常是路由或部署漏掉了一步,而不是誰貼了一條死連結。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
大概就三種樣子。第一種,SPA 的路由用點的可以開,一重新整理或用新分頁開就 404——點擊是 History API 處理掉的,重新整理卻是一個伺服器沒有規則可對的真實 HTTP 請求。第二種,部署完馬上有某個檔案 404——快取裡那份 `index.html` 還在要上一版建置的雜湊檔名。第三種,同一個 URL 在網址列打得開,`fetch` 卻 404,這通常代表請求跑到別的地方去了:別的來源、多了或少了一段代理前綴,或是相對路徑用目前的路由當基準去解,而不是用網站根目錄。
該怎麼做
去讀網路面板裡實際送出的那個請求 URL,不要讀你原始碼裡寫的那個——兩者的差別就是這個 bug:多一個開頭斜線、變成兩層的 `/api/api/`、路由器當成另一條路由的結尾斜線,或是一台你根本沒打算呼叫的主機。重新整理會壞的那種,伺服器需要一個 History API 的後備規則,把對不上的路徑都回 `index.html`:nginx 是 `try_files $uri /index.html`,其他主機用等價的 rewrite。靜態輸出的網站還要跟主機講好 URL 到底要不要以斜線結尾。舊檔案那種,把 HTML 本身設成不可快取,只讓雜湊過的檔案保持不變,這樣瀏覽器就不可能一直抓著一份指向已刪除檔案的舊文件。
為什麼會看到它
要嘛你的路由表裡沒有這組方法加路徑,要嘛在應用程式看到之前,前面有東西改寫了路徑。從外面看兩者一模一樣,而反向代理通常就是兇手:`location /api/` 底下的 `proxy_pass` 有沒有以斜線結尾,決定了前綴會不會被剝掉,所以差一個字元,應用程式看到的就是 `/users` 或 `/api/users`。剩下兩個是大小寫和結尾斜線:在 Linux 來源機上 URL 路徑是區分大小寫的,而多數路由器在你沒特別設定時,會把 `/users` 和 `/users/` 當成兩條不同的路由。
該怎麼做
先問應用程式它實際註冊了哪些路由——每個框架都有列路由的指令——然後拿它去對存取紀錄裡那條真正的路徑,不是對你原始碼裡的路徑。接著把代理排除掉:`curl --resolve example.com:443:203.0.113.10 https://example.com/api/users` 會照樣送出原本的主機名稱和 SNI,但直接連到來源機的位址,所以這樣還是 404 就是應用程式,404 不見了就是代理。來源機那邊的存取紀錄也要看:一個沒出現在裡面的請求代表它根本沒到,那就要往 DNS、走錯虛擬主機,或某台預設伺服器安靜地接走了你設定裡沒有涵蓋的主機名稱去查。
為什麼會看到它
這個網址以連結的形式存在,但以頁面的形式不存在。可能是打字或複製的時候少了一個字元,可能那一頁真的被搬走或刪掉了,也可能連結來自舊的信件、書籤或搜尋結果,而網站後來重新整編過。搜尋引擎會在頁面消失之後還把它留在索引裡一陣子,所以從搜尋結果點進去看到 404 是很平常的事,不代表你的裝置或連線有問題。
該怎麼做
先看網址列:少一個字母、從聊天視窗貼過來時夾帶了空白、信件裡被折成兩行的網址,這三種就能解釋大部分情況。接著把網址一段一段往回砍,`/blog/2024/some-post` 砍成 `/blog/2024/`,再砍成 `/blog/`,砍到有一頁打得開,再從那裡往下找。搬過家的頁面,通常網站自己的搜尋會比搜尋引擎更快找到。如果它是真的沒了、而你還需要裡面的東西,Internet Archive 的 Wayback Machine 常常還留著當時的副本。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS -o /dev/null -L -w '%{http_code} %{num_redirects} %{url_effective}\n' https://example.com/no-such-page`-sS` 會把進度列拿掉但保留錯誤訊息,`-o /dev/null` 把錯誤頁的內容丟掉,`-L` 會跟著 `Location` 標頭走,`-w` 則印出最後的狀態碼、跟了幾次轉址,以及回出這個狀態的那個 URL。最後那一欄才是這行指令的重點:curl 自己的文件寫著,`%{url_effective}` 要在你叫它跟著轉址走的時候才最有意義,而一連串轉址走到底才出現的 404,跟你自己打的網址就 404 是兩種不同的 bug——它代表有一條改寫規則把你送去了一個不存在的地方。你以為是直達的網址卻出現 `%{num_redirects}` 大於零,那就是線索。至於 404 是邊緣節點還是來源機做的,同一個請求跑兩次就知道:一次照原樣,一次加上 `--resolve example.com:443:203.0.113.10`,它會照樣送出原本的主機名稱和 SNI,但連到你指定的位址。兩次答案不一樣,就代表邊緣節點正在發一份來源機沒有的東西——以這個狀態碼來說,通常是一份被快取起來的 404。
RFC 9110 §15.5.5 把 404 定義成:來源伺服器找不到目標資源目前的表示,或者不願意透露它存在。這兩半都會影響你怎麼查。前半的意思是,404 只在講「現在這個 URL」,它完全沒有說這個資源以前存不存在、以後會不會出現;同一節還寫了,如果伺服器知道這個狀況是永久的,410 (Gone) 是更好的答案。後半就是 404 和 403 會重疊的原因:§15.5.4 明白允許一台不想承認某個被禁資源存在的來源伺服器,改回 404。所以你很確定存在的 URL 卻回 404,有可能是一個換了裝的存取控制決定。還有一點會在事故當下嚇到人:404 是 RFC 9110 §15.1 列為可依啟發式規則快取的少數狀態碼之一,就算你沒有寫任何 `Cache-Control`,中間的代理或 CDN 也可以把它存起來。於是一個在壞掉的部署期間回 404 的 URL,即使部署修好了還是會繼續回 404,直到那份存起來的回應過期、或有人去清掉為止。最後,404 對方法沒有任何意見:對一條只收 GET 的路徑送 POST,那是 405 不是 404,在那裡回 404 的框架,等於選擇不告訴你錯的是哪一半。
| 容易搞混的 | 怎麼分 |
|---|---|
| 403 | 403 是「我看懂了,但我拒絕」,404 是「這裡沒有東西」。RFC 9110 §15.5.4 是刻意把兩者弄模糊的:它明白允許一台不想承認某個被禁資源存在的來源伺服器改回 404。所以當你很確定存在的網址回了 404,先帶上認證資訊再試一次,然後才去找路由的 bug——物件儲存的儲存桶和私有版本庫就是這樣做的。 |
410 | 410 (Gone) 是伺服器知道這個資源已經永久結束時該給的答案,RFC 9110 說在那種情況下 410 比 404 更合適。差別是給機器看的信號,不是給人看的:404 會招來重試,410 則是告訴索引器不用再問了。如果你是刻意要下架內容,410 比較誠實,從搜尋結果消失得也比較快。 |
| 405 | 405 (Method Not Allowed) 的意思是路徑在,只是不收你用的那個方法,而 RFC 9110 要求回應要帶一個 `Allow` 標頭,列出真正可用的方法。對一條只收 GET 的路由送 POST 卻回 404 的框架,規格上是允許的,但它把答案裡有用的那一半藏起來了——所以在懷疑路徑之前,先確認方法。 |
soft 404 | soft 404 是那種內容裡寫著「找不到」、狀態行卻是 200 的頁面。HTTP 沒有禁止這樣做,但搜尋引擎會把錯誤頁當成內容收進索引,而腳本看到成功的狀態碼,就會把那段道歉文字當成資料去解析。如果一頁你明知不存在、用 curl 打卻沒有回 404,那個不一致本身就是 bug。 |
nginx | nginx 會回 404 的地方不只「檔案不在」一種。以 `=404` 結尾的 `try_files` 是刻意回的;`Host` 對不上任何 `server_name` 的請求會落到預設伺服器,而那台通常是一個根目錄空空的樣板;掉了結尾斜線的 `alias` 則會組出一條少一層目錄的路徑。錯誤紀錄裡會寫它實際去試的那條路徑,那是分辨這三種最快的方法。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 404 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。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,會把值班的人送去堆疊裡錯的那一半。
404 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。