當網頁只顯示「操作失敗」或其他通用文字時,工程團隊最容易犯的錯,是把畫面文字當成伺服器真正拋出的訊息。實際上,這段文字可能只是前端依 HTTP 500 狀態選出的固定文案;後端發生了什麼、在哪一層失敗、是否還有內層例外,畫面完全沒有提供答案。若伺服器同時沒有留下可查詢的診斷記錄,團隊只能依序猜測資料存取、快取、序列化、驗證與回應流程,不但耗時,也很難證明哪個候選原因已被排除。
較可靠的設計不是把完整例外送到瀏覽器,而是建立一條可驗證的關聯鏈:每次未處理例外產生一個短而可辨識的 errorId;伺服器用同一個值記錄路由、例外鏈、堆疊與必要情境;呼叫端只收到安全、穩定的 500 回應與該識別碼。操作者回報時間、操作與 errorId 後,維運人員便能直接定位同一筆事件。本文聚焦這個「回應與診斷關聯」契約,不討論相依注入生命週期、模型繫結、路由約束、資料庫結構變更或 Exception.TargetSite,因此不會與那些主題重疊。
為什麼通用 500 文案不足以除錯
通用 500 文案只能維持一致的對外回應,無法指出根因;可追查設計必須另外保存關聯識別與受限診斷事件。
HTTP 狀態碼只描述結果類別,不能代替根因證據。相同的 500 可能來自業務規則以外的例外、資料寫入失敗、序列化失敗、回應已開始後的錯誤,或診斷元件本身再次失敗。前端若把所有 500 映射成同一句文字,使用者體驗可以保持一致,但工程端不能因此推論後端訊息內容。第一個診斷動作應是確認文字由哪一層產生,再查看網路回應是否包含結構化內容,而不是搜尋與畫面文案相同的伺服器例外。
另一個常見缺口是只設定錯誤處理頁,卻沒有定義要記錄哪些欄位。錯誤頁能改善顯示,不代表事件已被持久保存;主控台輸出在背景服務、容器或集中代管時也未必形成可長期查詢的證據。Microsoft 的 ASP.NET Core 錯誤處理文件明確區分開發時的詳細頁面與正式執行時的處理方式,並提醒詳細例外資訊不應公開。這代表設計必須同時滿足兩個方向:對外縮小資訊,對內保留足夠診斷內容。
建議把 500 契約拆成三個面向。第一,回應只包含固定標題、狀態與 errorId,不得直接回傳例外文字或堆疊。第二,診斷記錄至少包含相同的 errorId、HTTP 方法、路徑、例外型別鏈與堆疊上限。第三,記錄寫入若失敗,不得取代原始例外,也不能讓錯誤處理流程遞迴。這三項缺一不可:只有回應識別碼卻沒有記錄,識別碼無法查;只有記錄卻沒有回應識別碼,現場回報仍只能靠時間猜;記錄失敗會向外再拋出,則診斷元件反而成為第二個故障來源。
選擇 IExceptionHandler,而不是把責任散落在控制器
集中使用 IExceptionHandler 可以統一 500 回應與診斷流程,避免每個控制器各自決定錯誤格式與記錄方式。
在 .NET 10 的 ASP.NET Core 中,可用 IExceptionHandler 實作集中式處理,並由例外處理中介軟體呼叫。Microsoft API 文件說明,TryHandleAsync 讓實作在 HTTP 管線中嘗試處理指定例外;錯誤處理文件也示範 AddExceptionHandler、AddProblemDetails 與 UseExceptionHandler 的組合。這個邊界適合處理跨端點的一致回應與診斷欄位,避免每個控制器各寫一套 try/catch,最後產生不同格式與遺漏。
MVC exception filter 仍有用途,特別是需要 MVC 動作情境或只想套用特定控制器時。Microsoft 的 filter 文件把 error handling 與 logging 列為橫切關注點,並說明 filter 能在 MVC 處理階段前後執行。然而,若目標是涵蓋整個 ASP.NET Core 要求管線,集中式中介軟體與 IExceptionHandler 通常邊界更清楚。不要同時讓多個層次都把同一例外標成「已處理」並產生不同回應;應先決定唯一擁有者,再讓其他層只補充情境。
errorId 不需要承載商業資料,也不應編碼路徑、使用者或時間。它的責任只是建立關聯。可使用 GUID 的固定長度表示,或由可替換的產生器提供值。長度必須符合記錄儲存欄位,格式必須容易由電話、截圖或工單轉錄,而且伺服器與回應必須使用完全相同的值。若識別碼可能重複,查詢時仍應搭配時間範圍與路由;若採完整 GUID,顯示成本較高,但碰撞風險更低。選擇何種格式不是核心,能被自動測試的關聯契約才是核心。
三步建立不會二次失敗的處理器
處理器應先固定安全回應,再嘗試寫入診斷事件,最後用獨立防護確保診斷失敗不會取代原始回應。
- 先定義安全回應。 固定回傳
application/problem+json或等價 JSON,狀態為 500,對外只放一般標題與errorId。不要把exception.Message、內層例外或堆疊加入回應。 - 再建立受限診斷事件。 由外到內走訪
InnerException,記錄型別與經清理的訊息;加入 HTTP 方法、路徑與追蹤識別;對訊息、路徑與堆疊設定長度上限。不要無條件保存 request body、query 或 header。 - 最後隔離診斷失敗。 將送往記錄介面的呼叫包在獨立
try/catch中。這個區塊失敗時可使用最小後備計數或平台事件,但不能再次拋出,也不能改用新的errorId。原始請求仍應收到原先規劃的 500 與同一識別碼。
以下範例使用 IExceptionHandler,把事件交給抽象的 IErrorEventWriter。實務實作可以接集中式記錄系統、結構化檔案或資料儲存,但處理器本身不依賴特定產品。範例刻意限制欄位,並把記錄失敗與回應產生分開:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed record ErrorEvent(
string ErrorId,
string Route,
string ExceptionChain,
string? Stack);
public interface IErrorEventWriter
{
ValueTask WriteAsync(ErrorEvent item, CancellationToken cancellationToken);
}
public sealed class GlobalErrorHandler(
IErrorEventWriter writer,
ILogger logger) : IExceptionHandler
{
public async ValueTask TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
var errorId = Guid.NewGuid().ToString("N")[..12].ToUpperInvariant();
var route = $"{context.Request.Method} {context.Request.Path}";
var chain = string.Join(" -> ", Walk(exception)
.Select(e => e.GetType().FullName));
var stack = Limit(exception.StackTrace, 6000);
try
{
await writer.WriteAsync(
new ErrorEvent(errorId, Limit(route, 240)!, Limit(chain, 2000)!, stack),
cancellationToken);
}
catch (Exception writeError)
{
logger.LogWarning(writeError,
"Error event recording failed for {ErrorId}", errorId);
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "The request could not be completed.",
Extensions = { ["errorId"] = errorId }
}, cancellationToken);
return true;
}
private static IEnumerable Walk(Exception value)
{
for (var item = value; item is not null; item = item.InnerException)
yield return item;
}
private static string? Limit(string? value, int max) =>
value is null || value.Length ();
builder.Services.AddExceptionHandler();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapControllers();
記錄內容的邊界:足夠追查,但不複製整個請求
診斷事件只需保存能關聯與分類失敗的欄位;完整請求內容會擴大風險與成本,不應預設全部複製。
「記得越多越好」不是可靠的診斷策略。要求本文、查詢參數與標頭可能包含個人資料或業務內容;不經分類全部保存,會擴大存取範圍與保留成本。全域處理器應預設記錄低風險且高辨識度的欄位,例如 errorId、伺服器追蹤識別、方法、正規化路徑、端點名稱、例外型別鏈、有限長度的清理後訊息與堆疊。需要業務欄位時,應由接近業務動作的程式以允許清單方式加入,而不是讓全域元件自行掃描整個 request。
例外鏈必須走到最內層,但不能假設每個訊息都適合長期保存。可先保存型別鏈,再針對已知例外建立清理規則。堆疊也應有長度上限,並確認儲存端欄位能接受該上限;否則超長內容可能讓整筆事件寫入失敗。截斷時最好額外加入 wasTruncated,避免讀者把截斷後的尾端誤認為完整根因。
非同步記錄有兩種不同含義。若在回應前 await,可以知道寫入是否完成,但會把記錄延遲加入請求時間。若採背景佇列,回應較快,卻必須處理程序終止、佇列容量與丟棄政策。不要單純呼叫非同步方法卻不保存回傳的 Task;那會讓失敗無人觀察。較穩健的方案是有界通道、明確的滿載行為、可觀察的丟棄計數,以及關閉時的有限排空時間。無論選哪一種,原始例外的處理結果都不應由第二套記錄流程任意改寫。
還要避免錯誤頁自我遞迴。若錯誤端點繼承會執行複雜前置工作的基底控制器,原始要求失敗後轉到錯誤端點,前置工作可能再次失敗,造成大量重複事件。健康檢查與錯誤端點應保持最小相依,或由前置邏輯明確略過。告警通道也應依「例外型別、正規化訊息、堆疊頂端」形成簽章,在短時間內聚合重複事件;節流只降低通知噪音,原始事件計數仍要保留,否則無法判斷影響範圍。
用隔離測試驗證 errorId 契約
隔離測試應同時驗證回應格式、errorId 與事件的一對一關聯,以及診斷寫入失敗時仍能完成一般 500 回應。
本文以 .NET SDK 10.0.400 建立獨立的 net10.0 Web 專案,僅綁定回送介面的動態連接埠,使用記憶體佇列保存事件,沒有連接任何外部資料服務。測試同時觸發兩條路徑:/fail 產生具有內層例外的失敗;/log-fail 模擬診斷寫入失敗。真實執行結果顯示,第一條路徑回傳 500 與 E0000001,記憶體事件也使用相同識別碼,例外鏈為 InvalidOperationException -> ArgumentException;第二條路徑即使寫入失敗,仍回傳 500 與 E0000002,程序沒有因診斷元件而中止。最後事件數為 1,符合第二筆刻意不入列的設計。
可用下列步驟重現核心驗證:
- 建立
net10.0Web 專案,註冊IExceptionHandler、AddProblemDetails與記憶體事件寫入器。 - 新增一個拋出巢狀例外的端點,再新增一個讓事件寫入器拋出測試例外的端點。
- 啟動至
127.0.0.1動態連接埠,依序呼叫兩個端點,檢查狀態、JSON、事件數與例外型別鏈。 - 執行 Release 建置並確認零警告、零錯誤,再重跑程式以排除只在偵錯組態成立的行為。
Build succeeded.
0 Warning(s)
0 Error(s)
/fail status=500 body={"title":"The request could not be completed.","status":500,"errorId":"E0000001"}
/log-fail status=500 body={"title":"The request could not be completed.","status":500,"errorId":"E0000002"}
records=1
record id=E0000001 route=GET /fail chain=InvalidOperationException -> ArgumentException
這個測試不是在證明某個記錄產品永遠可用,而是在固定四個可以回歸的行為:處理器確實接到未處理例外;對外回應沒有洩漏例外內容;同一 errorId 能連接回應與事件;事件寫入失敗不會覆蓋原始失敗。日後若改用其他儲存方式,只要保留這組契約測試,就能在整合前先發現格式漂移或例外遞迴。
驗證清單與上線判準
上線前應以可觀察條件逐項驗證關聯一致性、對外資訊最小化與記錄元件失敗時的回應韌性。
- 關聯一致性: 對測試端點送出要求,擷取回應的
errorId,再用同一值查詢記錄;應恰好找到一筆主要事件,且方法與路徑相符。 - 資訊最小化: 在測試例外訊息放入辨識字串,確認 500 body 不含該字串、例外型別或堆疊,只包含一般標題、狀態與
errorId。 - 失敗隔離: 讓事件寫入器故意失敗,確認仍收到預期格式的 500,且處理器不會再次進入自身,也不會把寫入例外改成另一個對外格式。
- 內層原因: 拋出至少兩層例外,確認記錄內的型別鏈順序由外到內,堆疊長度限制與截斷旗標符合設定。
- 錯誤端點最小化: 若使用重導或重新執行錯誤端點,確認它不會執行可能再次失敗的共用前置流程,並以單次要求只產生一次主要事件為驗收條件。
- 告警去重: 在短時間重複觸發同一簽章,確認事件總數仍可觀察,而通知數依政策聚合,不會由錯誤頁再次放大。
上線完成的定義不只是「瀏覽器看見比較友善的文字」。真正完成應包含:所有未處理要求都有可查的識別碼;回應格式有自動測試;記錄欄位有長度與清理規則;寫入故障有可觀察訊號;錯誤端點不會遞迴;值班流程知道如何用 errorId、時間與路由定位事件。如此一來,下一次 500 不再從猜測開始,而是從同一筆、可核對的證據開始。
Microsoft 官方參考資料
官方文件用來核對 ASP.NET Core 錯誤處理、Problem Details 與處理器 API 行為;本文的狀態契約另由本機測試驗證。
- Handle errors in ASP.NET Core:核對正式執行時的錯誤處理、
UseExceptionHandler、Problem Details 與資訊揭露原則。 - IExceptionHandler Interface:核對
TryHandleAsync的用途與介面契約。 - Filters in ASP.NET Core:核對 filter 的 MVC 管線位置與橫切關注點適用範圍。