@ExceptionHandler 為什麼攔不到?先看那個異常死在哪一層
先別管 handler 要怎麼寫。先看一個異常被 throw 出來之後,在變成回給前端的那包 JSON 之前,路上會經過誰的手。
這條路比大部分人想的短。而 @ExceptionHandler 只站在其中一小段。
搞清楚它站在哪,你就會知道為什麼有些錯誤永遠繞過你的 @RestControllerAdvice,而且再多寫幾個 handler 也救不回來。
一個 request 進來,其實要穿兩層皮
外面那層是 Servlet 容器。Tomcat 收到連線、解析 HTTP、然後把這個 request 丟進 filter 鏈。你寫的 JWT 驗證 filter、Spring Security 那一整串 filter、log 用的 OncePerRequestFilter,全部住在這一層。
裡面那層才是 Spring MVC。filter 鏈跑完之後,request 交給 DispatcherServlet,它去查這個 URL 對應哪個 controller method,做參數綁定、呼叫你的方法、把回傳值序列化成 JSON。
@ExceptionHandler 住在裡面那層。它不是掛在整個請求上的保險絲,它是 DispatcherServlet 內部的一個機制。
這句話是整篇的地基,值得再讀一次。
拆開 DispatcherServlet:那個 try 到哪裡結束
DispatcherServlet.doDispatch() 大致長這樣(把無關的細節拿掉):
1 | try { |
processDispatchResult() 裡面如果發現有異常,就會走進 processHandlerException(),然後拿著這個異常去問一串 HandlerExceptionResolver。預設註冊三個,照順序問:
ExceptionHandlerExceptionResolver— 去找有沒有@ExceptionHandler吃這個型別ResponseStatusExceptionResolver— 處理標了@ResponseStatus的異常,還有ResponseStatusExceptionDefaultHandlerExceptionResolver— 把 Spring MVC 自己的內建異常轉成對應的狀態碼
誰先回應,流程就在誰那裡結束。你的 @RestControllerAdvice 掛在第一個被問的 resolver 上,所以平常用起來又快又順,這給人一種「它什麼都攔得到」的錯覺。
錯覺的來源就是那個 try。它只包住「找 handler 到呼叫 controller 到序列化回傳值」這一段。異常要能被你的 handler 看到,唯一條件是:它必須在那個 try 的括號裡面被丟出來。
掉在 try 外面的異常,沒有人接
filter 裡丟出來的異常,時間點在 DispatcherServlet 被呼叫之前。那個 try 還沒開始。
於是異常一路往上冒,冒出 filter 鏈、冒回 Tomcat。容器發現這個請求炸了,就做它唯一會做的事:把請求轉發到 /error。Spring Boot 在那裡自動配了一個 BasicErrorController,它回給你一包長這樣的東西:
1 | { |
沒有你定的錯誤碼、沒有你的訊息格式,前端同事拿到之後會來問你這是什麼。這包 JSON 不是「你的 handler 寫錯了」的證據,它是「你的 handler 根本沒被叫到」的證據。這兩件事的修法完全不一樣,而大部分人的第一反應是去改 handler。
想像一間公司把客訴流程設在客服部門,寫得非常完整,各種情況都有 SOP。結果有位客人在大門口就跟警衛吵起來,被請出去了。客服部門那份 SOP 一個字都沒錯,只是那件事從來沒走到客服桌上。異常也是這樣:路徑不同的東西,寫得再完整也接不到。
三個最常見的「攔不到」現場
第一個是 Security 的認證失敗。 JWT 過期、簽章不對、header 沒帶,這些判斷都發生在 filter 裡。Spring Security 自己有一套接法:ExceptionTranslationFilter 會攔 AuthenticationException 交給 AuthenticationEntryPoint,攔 AccessDeniedException 交給 AccessDeniedHandler。你想改 401 的回應格式,要去改這兩個,寫 @ExceptionHandler(AuthenticationException.class) 是無效的。
還有一個更容易踩的:你自己寫的 JWT filter 裡如果 parse token 失敗直接丟了一個 RuntimeException,那連 ExceptionTranslationFilter 都不會處理它——那個 filter 只認得上面那兩種型別。這個異常會一路冒到容器,變成 500,而你本來想回的是 401。
第二個是 @Async。 方法一標上去,程式碼就跑在別的執行緒上。呼叫端的 try 早就結束了,異常沒有任何路徑可以回到 DispatcherServlet。回傳型別是 void 的話,要在 AsyncConfigurer 裡給一個 AsyncUncaughtExceptionHandler;回傳 CompletableFuture 的話,異常包在那個 future 裡面,你得自己去 exceptionally() 拆。什麼都不做的話,它會安靜地消失,連 log 都不一定有。
第三個是 404。 你可能試過寫 @ExceptionHandler(NoHandlerFoundException.class) 然後發現它從來沒被觸發過。因為 DispatcherServlet 找不到 handler 的時候,預設不丟異常,它直接回 404。要讓它丟,得開 spring.mvc.throw-exception-if-no-handler-found,而且通常還要一起關掉靜態資源的 fallback mapping,否則請求會先被靜態資源的 handler 接走,異常同樣不會出現。這兩個開關的預設值在幾個 Boot 版本之間調整過,我的習慣是不要憑記憶,直接在自己的版本上打一個不存在的路徑測一次,三十秒的事。
補法:在事情發生的那一層接
原則只有一句:接的地方要跟丟的地方在同一層。
filter 層的錯誤,就在 filter 層自己包起來。做法是加一個放在鏈最前面的 filter,把後面整串 doFilter 包在 try 裡:
1 |
|
它放在鏈的最前面,是因為要包住的是「後面所有人」。放中間就只保護得到它後面那幾個。
用 ProblemDetail 而不是自己定一個 ApiError record,是為了讓兩層的輸出長得一樣。它是 Spring 6 開始內建的類別,對應 RFC 9457 那份「HTTP API 錯誤回應」的規格,欄位就是 type、title、status、detail、instance,額外欄位靠 setProperty() 掛上去。MVC 層想用同一個格式的話,讓你的 advice 繼承 ResponseEntityExceptionHandler,Spring 內建的那批異常就會自動吐成 ProblemDetail,你只要補自己的業務異常。
至於自訂的業務狀態碼要不要塞進 HTTP status,我的做法是分開:HTTP status 給機器判斷(前端的攔截器、監控、重試邏輯靠它),code 欄位給人判斷(客服回報、log 追蹤靠它)。把「訂單庫存不足」硬塞成一個自創的 4xx 數字,看起來很精確,實際上是把兩種用途混在同一個欄位裡,之後想拆會很痛。
這把尺可以量的不只有異常
回頭看整件事,@ExceptionHandler 攔不到某些異常,不是設計上的缺陷,是它從來就沒宣稱要管那麼寬。是我們自己把「全域」這兩個字讀成了「全部」。
而這個形狀,在 Spring 裡到處都是。
@Transactional 在同一個類別裡被自己的另一個方法呼叫時失效,因為交易攔在 proxy 上,內部呼叫沒有經過 proxy。AOP 切面對同類內部呼叫沒反應,同一個原因。HandlerInterceptor 抓不到 async 完成後的處理,因為 interceptor 掛在 dispatch 週期上,async 的收尾已經是另一段生命週期。
它們全是同一句話的不同版本:你的機制裝在 B 層,事情發生在 A 層。
所以下次遇到「我明明設了,怎麼沒生效」,先不要加第二個設定。先問一句:這個東西被丟出來的那一刻,我的攔截機制在不在現場。不在的話,加幾個 handler 都是白費,該做的是換一層裝。
(本篇來源:我自己的 Notion 開發筆記,內容重新拆解與補寫;機制描述以 Spring Framework 6 / Spring Boot 3 的行為為準。)




































