錯誤回應設計的現況 2026,標準寫進程式碼,規約盡量最小化

前一篇文章「Web API 設計的現況 2026,現在應遵循的標準與事實標準清單」中,我把這 10 年來變化最大的一個領域列為錯誤回應。這篇文章就是對這部分的深入探討。

談到錯誤回應設計,常常會聚焦在 RFC 9457(Problem Details)的 body 格式,但實際上,接收錯誤的客戶端看到的並不只有 body。狀態碼、標頭、body 這三層都有各自的規範與事實標準,只要其中任一層缺失,監控、重試或表單錯誤顯示的某一部分就會壞掉。

這篇文章會整理出這三層各自「現在應遵循的東西」。最後也會寫到,如何把這些標準落實到專案中。

預設讀者是接下來要決定 Web API 錯誤格式的人。

錯誤是由 3 層構成的

層級要傳達什麼誰會看正典狀態碼錯誤分類。是否可重試、是否可快取監控、CDN、HTTP 用戶端、代理伺服器RFC 9110(僅 429 來自 RFC 6585)標頭錯誤的附帶資訊。何時重試、如何驗證HTTP 用戶端、SDKRFC 9110 / 6750 等本文內容主體具體發生了什麼。用於使用者顯示與復原處理的材料應用程式程式碼RFC 9457錯誤回應三層圖解.png

上面兩層是由 HTTP 的通用基礎設施機械式解讀的領域,body 則是由應用程式解讀的領域。理解這個分工之後,後面會出現的「錯誤不能用 200 回傳」「錯誤代碼和狀態碼不是同一件事」就會知道其實都是同一個原理的不同說法。

第 1 層 狀態碼,正典是 RFC 9110

定義方法與狀態碼語義的現行正典是 RFC 9110(2022 年)。長年被引用的 RFC 2616 與 7231 已整合並被它取代。錯誤中最常見的代碼裡,只有 429 Too Many Requests 是出自 RFC 6585(2012 年),該文件定義了額外的狀態碼。

大分類,400 番台與 500 番台

錯誤系的狀態碼分成兩個番台,這個區分是一切的基礎。

  • 400 番台(用戶端錯誤):請求本身有問題。即使重送同一個請求,結果也不會改變,所以用戶端需要修正請求
  • 500 番台(伺服器錯誤):伺服器端有問題。請求本身可能是正確的,隔一段時間後重送同一個請求也許就會成功

「重試是否有意義」這個後面會提到的用戶端規約,就是直接從這個分類導出的。

錯誤會用到的代碼一覽

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,所以這一層的資訊只能在這一層表達。

第 2 層 標頭,與狀態碼一起具有義務的東西

標頭層很容易被忽略,但其實有「如果回這個狀態碼,就必須帶這個標頭」這種規範上的組合。

401 需要 WWW-Authenticate

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 需要 Allow

回傳 405 Method Not Allowed 時,必須以 Allow: GET, POST 這種形式回傳允許的 HTTP 方法列表,這是 RFC 9110 的要求。

429 與 503 需要 Retry-After

這個標頭用來告訴對方「何時可以再試一次」。可以使用秒數(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。這與前一篇提到的「標準化跟不上,實作先變成事實標準」的情況完全一致。

第 3 層 本文,RFC 9457 Problem Details

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 件事。

  1. Content-Type 必須是 application/problem+json
  2. 5 個成員(type / title / status / detail / instance)的意義被保留,但全部都可省略
  3. 可以自由加入其他擴充成員

所有成員都可省略,擴充也自由。也就是說,它不是「捨棄自訂格式並全面改用這種格式」的型別,而是「替既有錯誤格式提供共同骨架」的共識,所以移轉門檻低,正是因為它夠寬鬆。

title 與 detail 的用法區分

  • 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 裡不該放的東西

錯誤回應同時也是攻擊者偵察的入口。detailtitle 裡不要放以下內容:

  • 堆疊追蹤、例外類別名稱、函式庫名稱與版本
  • SQL 語句或查詢片段
  • 內部 ID、內部主機名稱、檔案路徑
  • 像「使用者存在,但密碼錯誤」這種能區分存在性的資訊(登入失敗應收斂成同一種錯誤)

除錯資訊寫到 log,回應裡只放 instance 的關聯 ID 來對照。這就是兼顧「對使用者友善、對攻擊者沉默」的做法。

哪些錯誤可以重試

客戶端規約也可以簡化成一句話:只有 429、503、504,以及帶有 Retry-After 的回應,才適合自動重試。多數 400 番台都屬於送出同一請求也會得到同樣結果的類型,所以重試不但沒意義,還可能把自己送進速率限制。

讓標準定著的方法:標準寫入程式碼,判斷寫入訓練器

那要怎麼把這些內容落實到專案裡?把本文內容整段複製到 CLAUDE.md 或 AGENTS.md 這類規約檔案裡,看起來很直覺,但我認為不太值得。現在的模型已經知道 RFC 9457 與狀態碼語義了。把模型本來就知道的事情每次都塞進 context,只是增加冗餘,得不償失。真正該做的是兩件事。

1. 標準寫入共用函式

把錯誤回應的組裝集中到單一地方,將本文中「已經決定好的事」全部寫進去。包含 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 },連這部分也一併限制。

2. 在訓練器中寫的只是專案學體的判斷

無法寫進共用函式的,只有那些不能用程式碼決定的「這個專案自己的判斷」。這些內容應該寫在訓練器,也就是 CLAUDE.md / AGENTS.md 或 .claude/rules/ 這種決定要讓模型讀什麼、怎麼讀的設定裡。

在思考放置位置時,最重要的是:這些規約不是在實作共用函式時需要,而是在呼叫它的時候才需要。要回 401 還是 403?要不要因為要隱藏存在性而回 404?這些判斷發生在回傳錯誤的一側,也就是 handler 或 routing 的層級。規約就應該作用在那一層。把 CLAUDE.md / AGENTS.md 放在呼叫層的目錄中,或是透過 .claude/rules/paths: 指向像 src/api/** 這種呼叫層路徑,兩者本質上都一樣。

而且,這個 path 指定如果能輕鬆寫出來,本身就是一種診斷。若共用函式的呼叫位置,也就是決定「要回錯誤」的程式碼散落在各個目錄裡,導致你無法用 path 收斂,只能把規約幾乎放在整個專案根目錄,那就代表問題不只是規約怎麼寫,而是設計本身就該重看。只要回傳錯誤的判斷層級是固定的,規約就只需要對準那裡,不會有模糊空間。

真正該寫的只有 3 類。

  1. 強制使用共用函式。所有錯誤回應都必須用這個函式組裝,只要一句話就夠
  2. 專案特有的判斷。像是標準允許你自己決定的部分,這個專案怎麼選。title / detail 是用中文還是英文、要不要對存在性做 404/403 的隱藏、登入失敗如何收斂、type 的 slug 或錯誤代碼命名規則等
  3. 與事實標準的差異與原因。偏離標準本身不一定不好,但如果沒有記錄,後來的人(以及 AI)就分不清楚是「不知道而偏離」還是「刻意決定而偏離」

範例大概就是這個程度的篇幅。

---
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


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝9   💬4   ❤️2
253
🥈
我愛JS
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登