小時候玩電腦的時候,最常看到的錯誤就是:
404 Not Found
那時候其實根本不知道 404 是什麼。
反正網站打不開、畫面怪怪的,對我來說就是:
「喔,出錯了。」
真的開始學程式之後才發現,原來這些數字不是隨便拿來代表「Error」而已。
每個 Status Code 都有自己的角色。
更有趣的是,這些角色本身就是 HTTP 用來說話的一種方式。
Backend 用它告訴 Frontend 這次 Request 發生了什麼,Frontend 再根據收到的結果,決定接下來要顯示什麼、讓使用者做什麼。
這也剛好接上昨天講的 API Contract。
成功時要怎麼回答需要約定,失敗時其實也一樣。
所以今天就來看:
一個 Request 失敗後,Backend 到底是怎麼把「發生什麼事」說清楚的?
像前面提到的 404 Not Found,其實就是一個很好的例子。
使用者想取得某個 Activity,但那個 Activity 根本不存在。
這時 Backend 回:
404 Not Found
不是因為 Server 壞掉了。
反而代表 Backend 有好好收到 Request,並且明確回答:
「你要的 Resource 不存在。」
我以前會把這種情況和真正的程式 Error 全部混在一起,但開始學 API 之後才發現:
「這次沒有成功」和「程式真的壞掉」,其實是兩件不同的事。
真的拆開來看,Web API 會遇到的失敗其實很多:
失敗
│
├─ Request 資料不合法
├─ 身分驗證沒有通過
├─ 沒有操作權限
├─ 找不到 Resource
├─ 目前 Resource/系統狀態發生衝突
└─ Server 發生非預期 Error
看起來明明都是「Error」,但對 Frontend 來說,接下來要做的事卻差很多。
沒登入
→ 可能導回登入頁
沒權限
→ 顯示「你不能做這件事」
活動不存在
→ 顯示找不到內容
輸入資料不合法
→ 告訴使用者哪裡需要修改
Server 出錯
→ 顯示暫時無法使用
所以 Backend 不能只告訴 Frontend:
「失敗了。」
還得先說清楚:
這次到底是哪一類結果。
這就是 HTTP Status Code(HTTP 狀態碼) 在做的事。
Day19 已經看過 HTTP 狀態碼的五大類:
| 類別 | 大致代表 |
|---|---|
1xx |
資訊性回應 |
2xx |
成功 |
3xx |
重新導向 |
4xx |
Client Error |
5xx |
Server Error |
前面終於認識了我最常看到的 404 Not Found。
那其他角色又都在說什麼呢?
接下來就來看幾個 Web API 裡常見的 4xx 和 5xx。
| 狀態碼 | 可以先怎麼理解 |
|---|---|
400 Bad Request |
Request 無法按照預期處理,例如輸入資料不合法 |
401 Unauthorized |
缺少或無效的身分驗證資訊 |
403 Forbidden |
身分已知,但沒有操作權限 |
404 Not Found |
找不到指定 Resource |
409 Conflict |
Request 和目前 Resource/系統狀態發生衝突 |
500 Internal Server Error |
Server 發生非預期 Error |
狀態碼先告訴使用端:
這次 Request 最後屬於哪一類結果。
但只知道「是哪一類」,還不一定知道具體發生了什麼。
假設 Backend 只回:
404
Frontend 知道:
找不到 Resource。
但一個網站裡可能同時有:
Activity
User
Friendship
Notification
到底是哪一個找不到?
所以 Response Body 裡通常還會再放更具體的錯誤資訊:
{
"message": "Activity not found"
}
把它放回整個錯誤回應裡看,就會更清楚:
Error Response(錯誤回應)
│
├─ Status Code:404
│
└─ Response Body
└─ message:"Activity not found"
└→ Error Message(錯誤訊息)
也就是說:
Status Code 負責分辨結果類型,Error Message 再補充具體發生了什麼。
但這份 Error Response 的格式本身也要固定。
如果 Backend 有時候回:
{
"message": "Activity not found"
}
有時候又回:
{
"error": "User not found"
}
Frontend 就得一直判斷,這次到底要讀 message 還是 error。
所以昨天 API Contract 講的事情,在失敗時一樣成立:
Error Response 的格式,也應該讓使用端可以預期。
尤其是 500,它和前面的 4xx 最大的差別,不只是代碼不同,而是 Backend 可能根本沒有預料到這個 Error 會發生。
前面看到的很多失敗,其實都是 Backend 本來就知道可能發生的情況。
先把它們分成兩邊:
失敗
│
├─ 預期失敗
│ → 系統本來就知道這種情況可能發生
│
└─ 非預期 Error
→ 程式執行途中真的出了問題
像「Activity 不存在」,就是預期失敗。
Backend 知道這種情況可能發生,也知道該怎麼回答:
找不到 Activity
→ 回 404
但如果遇到的是:
Database 連線突然失敗
程式讀到不該是 undefined 的值
外部服務突然拋出例外
就比較像是:
「這個真的不是原本預期會發生的。」
這類非預期 Error,最後通常會回:
500 Internal Server Error
(內部伺服器錯誤)
所以 404 和 500 最大的差別,不只是數字不同。
404
→ Backend 知道發生了什麼
→ 也知道該怎麼回答
500
→ Server 遇到了非預期 Error
但非預期 Error 不會自己突然變成一個 500 Response。
它在 Code 裡發生之後,還得先解決另一個問題:
誰把它接住?怎麼往外傳?最後又由誰把它變成 HTTP Response?
前面知道了,非預期 Error 不會自己變成一個 500 Response。
從 Code 裡發生 Error,到最後 Frontend 收到 HTTP Response,中間其實是一整套後端錯誤處理(Backend Error Handling)的流程。
先看整條路:
HTTP Request(HTTP 請求)
↓
Route(路由)
↓
Controller / Service(控制器/服務層)
↓
發生 Error
↓
Error 被接住、往外傳
↓
Error Handler(錯誤處理器)
↓
Status Code + Error Response
(狀態碼+錯誤回應)
↓
Frontend(前端)
把中間這段 Error 被接住、往外傳 放大來看的話會長這樣:
throw
→ Code 把 Error 往外拋
try/catch
→ 外層把 Error 接住
next(error)
→ 把 Error 交給 Express
Error Handler
→ 集中處理 Error,轉成 Response
接下來,就從 throw 開始,看這顆 Error 是怎麼一路被傳下去的。
throw:先把 Error 往外拋假設 Service 執行到一半,發現拿到的資料出現不該發生的狀況:
async function findActivity(id) {
const activity = await db.activity.findUnique({
where: { id }
});
if (activity && !activity.ownerId) {
throw new Error('Activity data is incomplete');
}
return activity;
}
看到這裡的:
throw new Error(...)
可以先把它理解成:
這一層發現 Error,先把它往目前呼叫它的外層拋。
Error 被拋出去之後,如果外層有 try/catch,就可以在那裡把它接住。
try/catch:把 Error 接住前面把 Error throw 出去之後,如果外層有 try/catch,就可以在這裡接住它。
例如 Controller 呼叫剛剛的 Service:
try {
const activity = await findActivity(id);
} catch (error) {
next(error);
}
如果 findActivity() 裡拋出 Error,程式就會進到 catch(error)。
這段流程可以先看成:
Service(服務層)
↓
throw Error(拋出錯誤)
↓
Controller(控制器)
↓
catch(error)(接住錯誤)
不過接住 Error,不代表一定要在這一層決定:
最後回什麼 Status Code?Error Response 又要長什麼樣?
如果不打算在這裡處理完,就可以再把 Error 往後交。
next(error):把 Error 交給 Express在這個 Express 的寫法裡,就會看到:
next(error);
可以先把它理解成:
目前這一層不把 Error 處理完,而是交進 Express 後面的錯誤處理流程。
於是剛剛那條路又多了一步:
Service
↓
throw Error
↓
Controller
↓
catch(error)
↓
next(error)
↓
Error Handler
↓
HTTP Response
到了這裡,Error 已經被往後交出去,接下來就要有人負責把它整理成最後的 Response。
如果每個 Controller 都各自決定:
Error 怎麼記錄?
回哪個 Status Code?
Error Response 長什麼樣?
Endpoint 一多,處理方式就很容易越來越散。
所以可以設一個共同的 Error Handler(錯誤處理器),讓沒有在前面處理完的 Error 最後走到同一個地方:
活動 Controller ─┐
好友 Controller ─┼─→ Error Handler
登入 Controller ─┘
↓
HTTP Response
概念上可能會看到這樣的寫法:
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({
message: 'Server error'
});
});
這個例子先示範最簡單的情況:收到 Error、記錄下來,再回一個 500 Response。
但 Error Handler 真正負責的,不只是:
「全部回 500。」
而是把走到這裡的 Error 記錄、判斷,再轉成適合的 HTTP Response。
那這三天的 API,就先走到這裡啦!
從怎麼說話、怎麼把規則定清楚,一路看到今天連「失敗」都要怎麼回答。
以前做功能時,我比較容易只看:
「正常情況有沒有跑成功?」
Error 出現了,就想辦法把它修掉。
但這次一路拆完才發現,對使用者來說,「失敗」其實也需要被好好設計。
不然他可能遇到的是突然一片空白、看不懂的錯誤訊息,甚至根本不知道現在發生了什麼。
所以前後端在對 API 時,不只成功會回什麼要講清楚,失敗時要怎麼回答,也需要一起對齊。
我現在會覺得,一個功能不是成功那條路跑得通就算做完了。
失敗的時候,系統能不能好好接住,也很重要。
而下一篇,還會碰到另一種「不能做」。
不是資料錯、不是權限問題,也不是 Server 壞掉,而是:
系統現在這個狀態,本來就不允許做這件事。
下一篇,就來看 State Machine(狀態機)。