- Published on
API 錯誤處理:分層架構與 TanStack Query 整合
- Authors
- Name
API 錯誤處理:分層架構與 TanStack Query 整合
在使用 TanStack Query 時,一個常見的錯誤是在 API 層使用 .catch() 處理錯誤,但沒有重新拋出錯誤。這會導致 TanStack Query 無法捕獲錯誤,進而無法觸發 onError callback,讓錯誤處理機制失效。
分層責任 (Separation of Concerns)
良好的錯誤處理架構應該遵循分層責任原則:
API 層:專注於「呼叫後端並回傳資料或丟出錯誤」
API 層的職責應該單純明確:
- 發送 HTTP 請求
- 回傳成功的資料
- 讓錯誤自然向上傳播
- 記錄所有 API 錯誤的技術細節(統一的錯誤日誌)
- 進行必要的資料格式轉換
- 處理自動重試邏輯(使用者不需要知道的技術細節)
API 層不應該處理:
- ❌ 業務邏輯判斷
- ❌ UI 更新
- ❌ 顯示 toast 或 alert
- ❌ localStorage 等副作用
- ❌ 決定如何呈現錯誤給使用者
UI / Mutation 層:決定「錯誤發生時要怎麼顯示給使用者」
UI 層應該負責:
- 決定錯誤的呈現方式(toast、inline error、modal 等)
- 是否顯示 toast 或 alert
- 提供重試機制給使用者(重試按鈕)
- 記錄使用者行為和上下文(analytics、使用者操作日誌)
- 根據不同錯誤類型顯示對應的使用者友善訊息
UI 層不應該處理:
- ❌ HTTP 請求細節
- ❌ API 層面的自動重試
- ❌ 底層的錯誤日誌(API 錯誤應在 API 層統一記錄)
常見錯誤:在 API 層吞掉錯誤
如果在 API function 裡使用 .catch() 但沒有繼續拋出錯誤,可能會:
- 吞掉錯誤(返回 undefined),讓呼叫端誤以為成功但資料是空的
- UI 層失去判斷錯誤的機會,只能額外做 null check,不直覺
- TanStack Query 無法觸發 onError,導致錯誤處理失效
// ❌ 錯誤示範:吞掉錯誤
export const uploadFile = (file) => {
return API.post('/upload', file)
.then((res) => res.data)
.catch((error) => {
console.error('Upload failed:', error)
// 沒有 throw, TanStack Query 會認為請求「成功」
// mutation.data 會是 undefined
// mutation.isError 會是 false ❌
// onError 不會被觸發 ❌
})
}
正確做法 1:讓錯誤自然向上傳播
API 層 - 保持簡潔
// ✅ 推薦:最簡潔的做法
export const uploadFile = (file) => {
return API.post('/upload', file).then((res) => res.data)
// 不使用 .catch(),讓錯誤自然向上傳播
}
UI 層 - 統一錯誤處理
const useUploadFile = () => {
return useMutation({
mutationFn: uploadFile,
onError: (error) => {
// UI 層決定如何呈現錯誤
if (error.response?.status === 413) {
toast.error('檔案太大,請選擇較小的檔案')
} else if (error.response?.status === 400) {
toast.error('檔案格式不正確')
} else {
toast.error('上傳失敗,請稍後再試')
}
// 記錄使用者行為和上下文
analytics.track('upload_failed', {
fileName: file.name,
errorStatus: error.response?.status,
userAgent: navigator.userAgent,
})
},
onSuccess: () => {
toast.success('檔案上傳成功')
analytics.track('upload_success')
},
})
}
正確做法 2:在 API 層處理並重新拋出
當需要在 API 層做以下處理時,可以使用 .catch() 並重新拋出錯誤:
- 轉換錯誤格式
- 記錄 API 層的技術日誌
- 添加額外的錯誤資訊
API 層 - 處理後拋出
// ✅ 在 API 層記錄技術細節後拋出
export const uploadFile = (file) => {
return API.post('/upload', file)
.then((res) => res.data)
.catch((error) => {
// 記錄 API 層的技術細節
logger.error('API_ERROR', {
endpoint: '/upload',
method: 'POST',
status: error.response?.status,
message: error.message,
timestamp: new Date(),
})
// 轉換錯誤格式(可選)
const enhancedError = {
...error,
context: {
fileName: file.name,
fileSize: file.size,
timestamp: new Date(),
},
}
// ✅ 重新拋出讓 UI 層處理
throw enhancedError
})
}
UI 層 - 決定呈現方式
const useUploadFile = () => {
return useMutation({
mutationFn: uploadFile,
onError: (error) => {
// UI 層根據錯誤決定如何顯示
if (error.response?.status === 413) {
toast.error('檔案太大,請選擇較小的檔案')
} else {
toast.error('上傳失敗,請稍後再試')
}
// 記錄使用者層面的失敗
analytics.track('user_upload_failed', {
reason: error.message,
})
},
})
}
進階:統一的 API 錯誤處理
對於大型專案,建議在 API 層使用攔截器統一處理:
// api/client.js
import axios from 'axios'
import logger from './logger'
const apiClient = axios.create({
baseURL: '/api',
})
// 統一的錯誤攔截器
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// 所有 API 錯誤都會被記錄
logger.error('API_ERROR', {
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
message: error.message,
timestamp: new Date(),
})
// 繼續拋出錯誤給 UI 層
return Promise.reject(error)
}
)
export default apiClient
// api/upload.js
export const uploadFile = (file) => {
return apiClient.post('/upload', file).then((res) => res.data)
// 不需要 .catch(),攔截器已經統一處理了
}
總結:核心原則
分層職責清單
| 職責 | API 層 | UI 層 |
|---|---|---|
| HTTP 請求 | ✅ | ❌ |
| 資料格式轉換 | ✅ | ❌ |
| API 錯誤日誌 | ✅ | ❌ |
| 自動重試邏輯 | ✅ | ❌ |
| 決定錯誤呈現方式 | ❌ | ✅ |
| 顯示 toast/alert | ❌ | ✅ |
| 提供重試按鈕 | ❌ | ✅ |
| 使用者行為日誌 | ❌ | ✅ |
| 業務邏輯 | ❌ | ✅ |
關鍵原則
- API 層專注在「資料流」和「技術細節」,不要吞錯誤,統一記錄所有 API 錯誤
- 錯誤統一交給 TanStack Query 的 onError 或 UI 層來決定要怎麼呈現給使用者
- 技術細節在底層處理,使用者體驗在上層決定
- 分層清楚,debug 也更好追蹤
- 一致的錯誤處理體驗,避免處理方式不統一
- 如果使用 .catch(),必須重新 throw error,否則 TanStack Query 無法捕獲錯誤