前一篇文章「Web API 設計的現況 2026,現在應遵循的標準與事實標準清單」中,我把這 10 年來變化最大的一個領域列為錯誤回應。這篇文章就是對這部分的深入探討。
談到錯誤回應設計,常常會聚焦在 RFC 9457(Problem Details)的 body 格式,但實際上,接收錯誤的客戶端看到的並不只有 body。狀態碼、標頭、body 這三層都有各自的規範與事實標準,只要其中任一層缺失,監控、重試或表單錯誤顯示的某一部分就會壞掉。
這篇文章會整理出這三層各自「現在應遵循的東西」。最後也會寫到,如何把這些標準落實到專案中。
預設讀者是接下來要決定 Web API 錯誤格式的人。
層級要傳達什麼誰會看正典狀態碼錯誤分類。是否可重試、是否可快取監控、CDN、HTTP 用戶端、代理伺服器RFC 9110(僅 429 來自 RFC 6585)標頭錯誤的附帶資訊。何時重試、如何驗證HTTP 用戶端、SDKRFC 9110 / 6750 等本文內容主體具體發生了什麼。用於使用者顯示與復原處理的材料應用程式程式碼RFC 9457
上面兩層是由 HTTP 的通用基礎設施機械式解讀的領域,body 則是由應用程式解讀的領域。理解這個分工之後,後面會出現的「錯誤不能用 200 回傳」「錯誤代碼和狀態碼不是同一件事」就會知道其實都是同一個原理的不同說法。
定義方法與狀態碼語義的現行正典是 RFC 9110(2022 年)。長年被引用的 RFC 2616 與 7231 已整合並被它取代。錯誤中最常見的代碼裡,只有 429 Too Many Requests 是出自 RFC 6585(2012 年),該文件定義了額外的狀態碼。
錯誤系的狀態碼分成兩個番台,這個區分是一切的基礎。
「重試是否有意義」這個後面會提到的用戶端規約,就是直接從這個分類導出的。
Web API 的錯誤實際上會用到的代碼其實沒有很多。先掌握每個代碼的意義。
代碼名稱意義400Bad Request請求本身不合法,伺服器無法解析。例如 JSON 壞掉、缺少必要參數、型別不對等401Unauthorized尚未驗證。名稱雖然是 Unauthorized,但實際意思是「未認證」,也就是沒有、無效或過期的認證資訊。代表目前還不知道你是誰403Forbidden已完成驗證,但沒有執行該操作的權限。知道你是誰,但不允許你做404Not Found指定的資源不存在405Method Not AllowedURL 存在,但不接受該 HTTP 方法(GET/POST 等)409Conflict 與資源目前狀態衝突的操作。像是編輯衝突、重複建立相同資源等410Gone曾經存在,但已永久刪除。與 404 不同,明確表示「不會再回來」422Unprocessable Content請求格式雖然能正確解析,但內容在語意上無法處理。驗證錯誤的常見選擇429Too Many Requests在一定時間內的請求數超過限制(速率限制)500Internal Server Error伺服器內部發生非預期錯誤,通常就是 bug 或例外502Bad Gateway 路徑上的閘道器(例如反向代理)從後端伺服器收到不合法的回應503Service Unavailable伺服器暫時無法處理請求。可能是過載或維護中504Gateway Timeout閘道器等待後端伺服器回應逾時### 實務中容易混淆的典型分法
即使知道各自的意思,在實際 API 中還是會遇到「到底該回哪個」的困惑。典型的混淆點其實都有固定答案。
混淆點如何區分400 vs 422400 是請求格式不合法(例如 JSON 壞掉)。422 是格式正確但語意上無法處理(例如驗證錯誤)。422 原本出自 WebDAV,但已在 RFC 9110 中提升為一般 HTTP 狀態碼401 vs 403401 是「尚未認證」(不知道是誰),403 是「已認證但沒有權限」。若要回 401,WWW-Authenticate 標頭是必須的(第 2 層會說明)404 vs 403若想隱藏資源是否存在,RFC 9110 明確寫到可以用 404 取代 403。對他人資源的存取若回 403,會洩漏「那個資源存在」404 vs 410410 Gone 是「曾經存在,但已永久消失」。適合用在希望讓用戶端放棄再次取得的情境409與目前資源狀態衝突的操作(例如樂觀鎖競爭、重複建立等)429 vs 503429 是以用戶端為單位的速率限制超過。503 是伺服器整體過載或維護。兩者都應搭配 Retry-After使用而第 1 層最重要的原則是,錯誤不要用 200 回傳。如果永遠用 200,再透過 { success: false } 表示錯誤,監控的錯誤率圖表會變平、CDN 可能快取不該快取的錯誤、HTTP 用戶端的自動重試也會失效。傳輸層基礎設施不會讀 body,所以這一層的資訊只能在這一層表達。
標頭層很容易被忽略,但其實有「如果回這個狀態碼,就必須帶這個標頭」這種規範上的組合。
WWW-Authenticate 是用來告訴用戶端「這個資源應該用哪種方式進行驗證」的標頭。依 RFC 9110,回傳 401 的回應必須包含這個標頭。因為 401 的意思是「請重新驗證」,所以也要一起告訴對方要怎麼重新驗證。
如果是以 Authorization: Bearer <token> 形式傳送存取權杖的 Bearer Token 驗證,RFC 6750 還更進一步,規定了可以用 error 屬性回傳錯誤種類的格式。
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
error 裡的值也已標準化為 3 種:invalid_request(相當於 400)、invalid_token(401。過期也屬於這個)、insufficient_scope(相當於 403)。已經不需要再自行設計 token 過期的表達方式了。
回傳 405 Method Not Allowed 時,必須以 Allow: GET, POST 這種形式回傳允許的 HTTP 方法列表,這是 RFC 9110 的要求。
這個標頭用來告訴對方「何時可以再試一次」。可以使用秒數(Retry-After: 120)或 HTTP 日期(Retry-After: Fri, 08 Aug 2026 07:00:00 GMT)。加上這個標頭後,守規矩的用戶端或 SDK 就能自動在適當的等待時間後重試。相反地,如果 429 沒有這個標頭,用戶端就只能憑猜測決定等待多久。
429 是「超過限制之後」才回傳的,但在超過之前,用來告知剩餘量的標頭目前還沒有標準。現況下的事實標準,是 GitHub 等服務使用的 3 個標頭組合。
標頭意義X-RateLimit-Limit目前視窗(例如 1 小時)內的請求上限X-RateLimit-Remaining目前視窗內剩餘的請求次數X-RateLimit-Reset限制重設的時間(通常是 UNIX epoch 秒)如果把這些標頭放在所有回應中,用戶端就能在撞上 429 之前先放慢速度。IETF 目前有將其標準化為 RateLimit / RateLimit-Policy 標頭的草案(draft-ietf-httpapi-ratelimit-headers),但截至 2026 年 8 月仍未成為 RFC。這與前一篇提到的「標準化跟不上,實作先變成事實標準」的情況完全一致。
body 格式的現行標準是 RFC 9457(2023 年發行,最早是 2016 年的 RFC 7807)。成熟度為 Proposed Standard,與 TLS 1.3、OAuth 2.0 一樣,屬於已被廣泛實作的標準化 RFC。回傳時使用 Content-Type: application/problem+json。
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://example.com/errors/insufficient-funds",
"title": "餘額不足",
"status": 403,
"detail": "餘額為 30 點,但這筆交易需要 50 點",
"instance": "/accounts/12345/transfers/67890"
}
先從 5 個欄位的意義開始。
欄位意義type識別錯誤種類的 URI。相當於應用程式專屬的錯誤代碼title該錯誤種類的簡短說明。給人看的,同一個 type 就應該永遠是同一句話statusHTTP 狀態碼。也把第 1 層的值帶進 bodydetail這一次錯誤特有的說明。具體值寫在這裡instance發生錯誤的具體資源 URI### 其實真正決定的只有 3 項
看到規格文件時,常會想像成很多厚重的要求清單,但 RFC 9457 實際上只決定了 3 件事。
Content-Type 必須是 application/problem+jsontype / title / status / detail / instance)的意義被保留,但全部都可省略所有成員都可省略,擴充也自由。也就是說,它不是「捨棄自訂格式並全面改用這種格式」的型別,而是「替既有錯誤格式提供共同骨架」的共識,所以移轉門檻低,正是因為它夠寬鬆。
title 對應 type 並固定不變。同一種錯誤每次都要是同一句話detail 會依發生情況而變。具體值寫在這裡type。如果開始對 title 做字串比對,日後只要改文案就會變成破壞性變更type 的 URI 不一定要能解析(只要能識別即可),但如果做成文件網址,錯誤回應本身就能成為文件入口type,就會視為 about:blank,意思是「除了狀態碼之外沒有更多資訊」instance 是發生錯誤的資源 URI。也可用來放日誌的關聯 ID,讓使用者只帶著錯誤回應就能定位對應的日誌實務上最常回傳的欄位級驗證錯誤,RFC 9457 本體沒有定義。這時就是擴充成員的用途,通常會加上一個 errors 陣列。
{
"type": "https://example.com/errors/validation",
"title": "輸入內容有誤",
"status": 422,
"errors": [
{ "field": "email", "code": "ALREADY_TAKEN", "message": "已經註冊過了" },
{ "field": "password", "code": "TOO_SHORT", "message": "請輸入 8 個字元以上" }
]
}
另外,從 7807 改訂到 9457,主要是追認這類擴充的用法,以及明確化 type 的解析方式,結構本身沒有改變。符合 7807 的實作與函式庫仍然可以直接使用。
各語言生態系的支援狀況差異很大(截至 2026 年 8 月,以各官方文件為準)。
框架Problem Details 支援Spring Framework 6 / Boot 3內建 ProblemDetail 類別。內建例外自動轉成 problem+json 預設關閉,可透過 spring.mvc.problemdetails.enabled=true 啟用ASP.NET Core[ApiController] 的錯誤會自動轉成 ProblemDetails(預設啟用)。Minimal API 則使用 AddProblemDetails()(自 .NET 7 起)NestJS沒有內建支援。預設格式是 { statusCode, message }。若要對齊,需透過例外過濾器自行實作Express / Fastify / Hono沒有內建支援。需要套件或自行實作在 Java 與 C# 中,標準已經進入框架本身;但在 JS/TS 中,決定錯誤格式仍然是設計者的工作。
衡量自己的錯誤設計是否有資訊量,可以用一個標準:如果從應用程式專屬的錯誤代碼可以唯一推出狀態碼,那這個錯誤代碼就沒有資訊量。例如錯誤代碼 40001 永遠只代表 400,那它只是狀態碼的複製品。反過來,如果只有狀態碼,客戶端就只能知道「回了 400」,卻無法分辨是信箱重複、密碼不足,還是其他原因,最後只會墜入比對日文訊息字串的地獄。
傳輸層的分類使用 status,應用層的識別使用 type(以及擴充成員)。RFC 9457 的核心意義,就是把這兩個層級能在同一個 body 中共存的位置定義出來了。
錯誤回應同時也是攻擊者偵察的入口。detail 或 title 裡不要放以下內容:
除錯資訊寫到 log,回應裡只放 instance 的關聯 ID 來對照。這就是兼顧「對使用者友善、對攻擊者沉默」的做法。
客戶端規約也可以簡化成一句話:只有 429、503、504,以及帶有 Retry-After 的回應,才適合自動重試。多數 400 番台都屬於送出同一請求也會得到同樣結果的類型,所以重試不但沒意義,還可能把自己送進速率限制。
那要怎麼把這些內容落實到專案裡?把本文內容整段複製到 CLAUDE.md 或 AGENTS.md 這類規約檔案裡,看起來很直覺,但我認為不太值得。現在的模型已經知道 RFC 9457 與狀態碼語義了。把模型本來就知道的事情每次都塞進 context,只是增加冗餘,得不償失。真正該做的是兩件事。
把錯誤回應的組裝集中到單一地方,將本文中「已經決定好的事」全部寫進去。包含 RFC 9457 的欄位結構與 Content-Type、狀態碼與標頭的對應關係(401→WWW-Authenticate、405→Allow、429/503→Retry-After)、以及 detail 不能放內部資訊。呼叫端只要使用像 problem(status, type, detail, extensions) 這樣的共用函式,讓系統變成只能產生正確回應的狀態。
寫進程式碼的規則,不需要再靠 AI 或人類記憶。檢查方式也會縮成「有沒有在共用函式以外自己組回應」這一點,而且這可以用 lint 機械式強制。
如果是 TypeScript,還可以用型別再多加約束。把不同狀態碼所需的必要參數定義清楚後,problem(429, ...) 若沒傳 retryAfter 就無法通過編譯,problem(405, ...) 也會強制要求 allow 清單。這樣「401 卻忘了 WWW-Authenticate」就不再是靠 code review 找出來的問題,而是直接變成編譯錯誤。與其說是把規約寫進程式碼,不如說是寫進型別。做到這一步,前面標頭義務的那些內容,就幾乎不可能忘記了。
Hono 的實作範例我放在 gist 裡。
保留彈性的地方只剩下擴充成員的接收槽,這裡通常會變成 Record<string, unknown> 之類的型別。老實說,這種簽名是我自己在 code review 時最常挑的部分;如果你的專案很常用到擴充,建議你依照錯誤種類定義更細的擴充型別,例如餘額不足就強制需要 { balance: number; required: number },連這部分也一併限制。
無法寫進共用函式的,只有那些不能用程式碼決定的「這個專案自己的判斷」。這些內容應該寫在訓練器,也就是 CLAUDE.md / AGENTS.md 或 .claude/rules/ 這種決定要讓模型讀什麼、怎麼讀的設定裡。
在思考放置位置時,最重要的是:這些規約不是在實作共用函式時需要,而是在呼叫它的時候才需要。要回 401 還是 403?要不要因為要隱藏存在性而回 404?這些判斷發生在回傳錯誤的一側,也就是 handler 或 routing 的層級。規約就應該作用在那一層。把 CLAUDE.md / AGENTS.md 放在呼叫層的目錄中,或是透過 .claude/rules/ 的 paths: 指向像 src/api/** 這種呼叫層路徑,兩者本質上都一樣。
而且,這個 path 指定如果能輕鬆寫出來,本身就是一種診斷。若共用函式的呼叫位置,也就是決定「要回錯誤」的程式碼散落在各個目錄裡,導致你無法用 path 收斂,只能把規約幾乎放在整個專案根目錄,那就代表問題不只是規約怎麼寫,而是設計本身就該重看。只要回傳錯誤的判斷層級是固定的,規約就只需要對準那裡,不會有模糊空間。
真正該寫的只有 3 類。
title / detail 是用中文還是英文、要不要對存在性做 404/403 的隱藏、登入失敗如何收斂、type 的 slug 或錯誤代碼命名規則等範例大概就是這個程度的篇幅。
---
paths: src/api/** # 對應會呼叫共用函式的層級(handler / routing)。如果直接把 CLAUDE.md / AGENTS.md 放在那一層,這行可以省略
---
# 錯誤回應
- 所有錯誤回應都必須透過 `problem()`(src/middleware/error/problem.ts)組裝,不可直接用 `c.json()` 回傳錯誤
- title / detail 一律以日文撰寫(因為使用者僅限國內)。不做多語系
- type 的 slug 使用 kebab-case。新增錯誤種類時,也要在 docs/errors.md 的清單中新增一行
- errors 陣列中的 code 使用 SCREAMING_SNAKE_CASE(例如:ALREADY_TAKEN)
- 隱藏存在性:存取其他使用者的資源時,回傳 404 而不是 403
- 登入失敗不拆分原因,統一收斂為同一種 401 回應
- 與事實標準的差異:驗證錯誤統一回傳 400,而不是 422(為了相容既有用戶端)
這裡完全沒有放模型本來就知道的標準說明。這樣的分量即使常駐載入,固定成本也幾乎是零。標準由程式碼負責守住,context 只傳遞專案自己的判斷。以現在的規約寫法來說,我認為這是最不浪費的分工。
三層各自都有正典,而大部分決定其實早就定好了。設計者剩下要做的,只有把標準寫進共用函式與型別,以及把程式碼無法決定的專案特有判斷寫進訓練器這兩件事。401 有沒有加上 WWW-Authenticate、哪些代碼可以重試。與其讓人靠記憶把本文當檢查清單,不如直接交給編譯器與 lint 去記住,這才是 2026 年的做法。
系列前一篇在這裡。
株式會社xincere正在招募沒有實務經驗的工程師與學生工程實習生,一起工作。
※ 關於シンシア的工作方式,可見這裡
在シンシア,每年約有 100 位沒有實務經驗的人應徵並接受技術面試。
透過這些經驗,我們將在此介紹「希望沒有實務經驗的人一定要培養的技術力」。
原文出處:https://qiita.com/tatsuya582/items/e5c56a2f7b976cfc17ea