// errors_db.go — 統一把 DB(pgx)錯誤映射成對外 API 錯誤(DB 接入塊 5.4)。 // // 兩個目的: // 1. **fail-fast 落地**:PG 連線失敗 / context 逾時 → 503 SERVICE_UNAVAILABLE(不回假資料、 // 不誤報 500),讓 load balancer 把這台拉出。對齊使用者拍板的「PG 都 fail-fast、不自動降級」。 // 2. **收掉塊 3 M1 技術債**:handler 過去把 raw DB error 字串串進 response // (`"... failed: "+err.Error()`)會洩漏 schema / SQL / 連線細節。本檔統一映射, // 對外只給穩定的 error code + 通用 message,raw error 只進 server log(含 request_id)。 // // 映射規則(依優先序): // // | 來源 | HTTP | code | // |----------------------------------------|------|-----------------------| // | context 逾時 / 取消(DeadlineExceeded / Canceled)| 503 | SERVICE_UNAVAILABLE | // | 連線層失敗(pgconn 無 SQLSTATE:refused/reset/EOF)| 503 | SERVICE_UNAVAILABLE | // | unique violation(SQLSTATE 23505) | 409 | CONFLICT | // | 其餘有 SQLSTATE 的 PG 錯誤(語法/約束等)| 500 | INTERNAL_ERROR | // | 非 DB 錯誤 / 未知 | 500 | INTERNAL_ERROR | // // ⚠️ not found 不在此映射:domain 層(device.ErrNotFound / auth.ErrInvalidToken 等)已把 // pgx.ErrNoRows 轉成自己的 sentinel,handler 應先用 errors.Is 比對那些 sentinel 回 404, // **再**把剩下的「真 DB 錯誤」交給 WriteDBError。這樣「正常的 not found」絕不會被誤判成 503。 package api import ( "context" "errors" "log/slog" "net/http" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgconn" ) // dbErrorClass 是 DB 錯誤分類結果。 type dbErrorClass struct { status int code string message string // 對外通用訊息(不含 raw DB 細節) } // classifyDBError 把一個 error 分類為對外的 (HTTP status, code, message)。 // // 不洩漏 raw DB error:回傳的 message 是固定通用字串,raw error 由 WriteDBError 寫進 server log。 func classifyDBError(err error) dbErrorClass { // 1) context 逾時 / 取消 → 503(依賴慢/不可用,fail-fast)。 if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable, "service temporarily unavailable"} } // 2) pgconn.PgError:PG server 端回的錯誤(有 SQLSTATE)。 var pgErr *pgconn.PgError if errors.As(err, &pgErr) { switch pgErr.Code { case "23505": // unique_violation return dbErrorClass{http.StatusConflict, ErrCodeConflict, "resource already exists"} default: // 其餘 PG 錯誤(語法、約束、權限…)對外一律 500,不洩漏 SQLSTATE / 欄位名。 return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError, "internal error"} } } // 3) pgconn.ConnectError 等「連不上 / 連線中斷」(無 SQLSTATE)→ 503。 // pgxpool 連線層失敗(connection refused / reset / EOF)多半包成 *pgconn.ConnectError, // 或為底層 net error。用 errors.As 抓 ConnectError;抓不到再保守視為連線問題前先看下一步。 var connErr *pgconn.ConnectError if errors.As(err, &connErr) { return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable, "service temporarily unavailable"} } // 4) 其餘未知錯誤 → 500(保守,不假設是 DB down 以免把程式 bug 也報成 503)。 return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError, "internal error"} } // WriteDBError 把一個 DB 操作錯誤映射成對外 API 錯誤並寫回 response,同時把 raw error 進 log。 // // op 是操作描述(如 "get device" / "list models"),只進 log、不對外。 // 對外只給 classifyDBError 算出的穩定 code + 通用 message(不洩漏 raw DB error)。 // // 用法(handler 內,已先處理過 domain sentinel 如 ErrNotFound 後): // // if err != nil { // WriteDBError(c, deps.Logger, "list devices", err) // return // } func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) { cls := classifyDBError(err) // raw error 只進 server log(附 request_id + 對外 code,方便對照),不進 response。 logOrDefault(log).Error("db operation failed", "op", op, "error", err, "http_status", cls.status, "code", cls.code, "request_id", RequestIDFrom(c)) WriteError(c, cls.status, cls.code, cls.message, nil) }