團隊在 NestJS 中建立了 Global Exception Filter,希望統一處理 API 錯誤,包括格式化回應、隱藏內部錯誤訊息等。原本其他 Controller 都運作正常,但就在我們套用局部 Filter 後,一切都發生了改變。
全域防線看似失效,但真的是這樣嗎?今天就來帶大家拆解 NestJS Exception Filter 的執行機制,以及為什麼局部 Filter 會影響全域的錯誤處理。
為了重現這個情境,我們先註冊一個全域 Filter:
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter<unknown> {
catch(exception: unknown, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<Response>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = isHttpException
? exception.message
: 'Internal server error';
response
// 標記這是由 Global Filter 處理的
.setHeader('x-error-filter', 'global')
.status(status)
.json({
statusCode: status,
message,
});
}
}
把它設定為全域:
@Module({
providers: [
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
],
})
export class Day26ControllerFilterSkipsGlobalModule {}
新增 PostsController 及測試端點:
@Controller('posts')
export class PostsController {
@Get('missing')
missing(): never {
throw new NotFoundException('Post 404 not found');
}
}
啟動專案並發送請求測試:
curl -i http://localhost:3000/posts/missing
得到回應:
HTTP/1.1 404 Not Found
x-error-filter: global
{
"statusCode": 404,
"message": "Post 404 not found"
}
可以看到 Global Filter 正常發揮作用,也保留了 NotFoundException 的 404 狀態碼。
接著,我們在 Controller 層加上 @UseFilters():
@Controller('posts')
@UseFilters(HttpExceptionFilter)
export class PostsController {
@Get('missing')
missing(): never {
throw new NotFoundException('Post 404 not found');
}
}
這個 Filter 只負責捕捉 HttpException:
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
catch(exception: HttpException, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<Response>();
const status = exception.getStatus();
response
.setHeader('x-error-filter', 'controller')
.status(status)
.json({
statusCode: status,
message: exception.message,
});
}
}
呼叫相同情境的端點:
curl -i http://localhost:3000/posts/missing
這次的回應變成了:
HTTP/1.1 404 Not Found
x-error-filter: controller
{
"statusCode": 404,
"message": "Post 404 not found"
}
雖然同一個端點仍然拋出相同的 NotFoundException,狀態碼也同樣是 404,但套用 Controller Filter 後,Global Filter 處理過的痕跡卻完全消失了。
這不只是回應格式不同的問題。如果 Global Filter 還負責錯誤追蹤或寫入稽核紀錄,這些處理邏輯也不會執行,可能導致部分錯誤缺少監控或追蹤紀錄。
這個問題的核心其實在於開發者對 Filter 執行機制的預期落差:Nest 不會執行所有符合條件的 Filter,而是只要找到第一個符合例外類型的 Filter,就會直接交給它處理並停止尋找。
當請求進入系統時,Guard 和 Pipe 會由外而內依序執行:

而 Interceptor 更是標準的洋蔥模型:

這很容易讓人產生誤會,以為 Exception Filter 也會先經過 Global 再進入 Controller;或者處理完 Controller Filter 後,還會把控制權還給 Global Filter。
但 Exception Filter 的運作邏輯截然不同。當例外發生時,Nest 會先按照以下順序排列候選的 Filter:

接著,它會依序檢查這些 Filter 宣告的 @Catch() 類型,找到第一個符合者就立刻把錯誤交給它處理,並終止尋找。
以剛剛的 /posts/missing 為例,整個處理過程如下:

因為 NotFoundException 繼承自 HttpException,所以 Controller Filter 成功攔截了它。Nest 把例外交出去後就收工了,自然不會再把同一個例外傳給後面的 Global Filter。
為了確認影響範圍,我們在該 Controller 新增一個拋出一般 Error 的端點:
@Get('unexpected')
unexpected(): never {
throw new Error('Database connection unexpectedly failed');
}
發送請求:
curl -i http://localhost:3000/posts/unexpected
測試結果顯示會由 Global Filter 處理:
HTTP/1.1 500 Internal Server Error
x-error-filter: global
{
"statusCode": 500,
"message": "Internal server error"
}
會有這樣的結果,是因為 Controller Filter 明確宣告了 @Catch(HttpException),所以當遇到一般的 Error 時,這個局部 Filter 並不會攔截。Nest 判定條件不符後會繼續往外尋找,最後自然交由設定為 @Catch()(捕捉所有例外)的 Global Filter 順利接手。
這證明了局部 Filter 並不是「關掉」整個 Controller 的全域設定,它只會攔下自己宣告要處理的特定例外。
throw 就能把例外傳給下一個 Filter?習慣了 Middleware 或 Interceptor 機制的開發者,遇到 Global Filter 沒被執行的情況,第一個直覺往往是:既然沒有 next() 可以呼叫,那我在局部 Filter 做完處理後,直接重新 throw exception,是不是就能交棒給外層的 Global Filter 了?
@Catch(HttpException)
export class HttpExceptionFilter {
catch(exception: HttpException): void {
console.log('controller filter handled');
// 試圖拋出錯誤來交棒給下一個 Filter
throw exception;
}
}
千萬別這麼做!Exception Filter 既沒有 Express middleware 的 next(error),也沒有 Interceptor 的 next.handle() 交棒機制。
Nest 選出第一個符合的 Filter 後,該 Filter 就是執行的終點。如果在 catch() 裡再次拋出錯誤,不僅無法將控制權還給 Global Filter,反而會因為錯誤未被妥善處理,導致未預期的錯誤。
如果我們希望局部 Filter 在處理特定需求的同時,仍然沿用共用的錯誤回應格式,NestJS 在請求生命週期文件中建議,可以透過繼承重用不同 Filter 的處理邏輯:
@Catch(HttpException)
export class HttpExceptionFilter extends GlobalExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost): void {
console.log('[posts] controller-specific error context');
// 呼叫父類別的共用邏輯
super.catch(exception, host);
}
}
此時的流程會變成這樣:

要注意的是,這不代表 NestJS 重新執行了 Global Filter。這次例外仍然只會由 Controller Filter 接手,super.catch() 只是透過繼承呼叫父類別的方法。
如果未來有更多 Filter 需要共用 Logger、Sentry 等功能,也可以考慮將這些功能抽離成服務,透過依賴注入重用,而不是全部集中在 Filter 的繼承關係中。
⚠️ 注意:
使用@UseFilters()時,建議傳入 Filter 類別,讓 NestJS 管理實例建立與依賴注入。如果 Filter 需要注入其他服務,應由 NestJS DI 容器管理,避免自行建立實例。
HttpException),同一個例外就不會再進入 Global Filter,沒有自動交棒機制。@Catch() 條件(例如一般的 Error),Nest 依然會繼續往外找,最後由 Global Filter 處理。super.catch() 顯式重用全域的處理流程。throw 不是交棒:Exception Filter 沒有 next() 機制,從 catch() 中再次拋出例外並不會讓 Nest 接著執行下一個 Filter。