# 結構化錯誤碼 套件路徑:`gateway/internal/library/errors`(import 建議別名 `errs`) ## 錯誤碼格式 8 碼 **SSCCCDDD**(十進位,左側補零顯示): | 段 | 名稱 | 範圍 | 說明 | |----|------|------|------| | SS | Scope | 00–99 | 服務 / 模組(見 `code/types.go` 常數) | | CCC | Category | 000–999 | 錯誤類別,**決定 HTTP / gRPC 映射** | | DDD | Detail | 000–999 | 業務細節,不影響 HTTP 狀態(`CatGRPC` 除外) | 範例:`10101000` → Scope=`10`(Facade)、Category=`101`(InputInvalidFormat)、Detail=`000`。 ``` 10101000 ^^ Scope = 10 ^^^ Category = 101 ^^^ Detail = 000 ``` --- ## 快速開始 ```go package myhandler import ( "net/http" errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" ) // 模組頂層綁定 scope(每個 binary / 套件一次) var errb = errs.For(code.Facade) func GetUser(w http.ResponseWriter, id string) error { if id == "" { return errb.InputInvalidFormat("缺少參數: id") } u, err := repo.Find(id) if err != nil { return errb.DBError("查詢失敗").WithCause(err) } if u == nil { return errb.ResNotFound("user", id) } return nil } func writeHTTP(w http.ResponseWriter, e *errs.Error) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Error-Code", e.DisplayCode()) w.WriteHeader(e.HTTPStatus()) // body: {"code":"10101000","message":"..."} } ``` ### 常用 API | 需求 | 用法 | |------|------| | 綁定 scope | `errs.For(code.Facade)` | | 語意化建構 | `errb.ResNotFound("user", id)` | | 自訂 category + detail | `errb.Code(code.SysTimeout, 42, "下游逾時")` | | 附加底層錯誤 | `e.WithCause(err)`(不可變) | | 更換 scope | `e.WithScope(code.LocalAPI)` | | 嚴格驗證 | `errs.New(scope, cat, det, msg)` → `(*Error, error)` | | 從 8 碼還原 | `errs.FromCode(10101000)` | | 日誌(可選) | `gateway/internal/library/errlog` + `slog` | --- ## Category → HTTP 完整對照表 與 `HTTPStatus()` 實作一致。未列出的 category 會 fallback 為 **500**。 ### A. Input(1xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 101 | `InputInvalidFormat` | **400** Bad Request | 格式錯、型別錯 | | 102 | `InputNotValidImplementation` | **422** Unprocessable Entity | 語意正確但無法依目前實作處理 | | 103 | `InputInvalidRange` | **422** | 數值 / 範圍不合法 | | 104 | `InputMissingRequired` | **400** | 必填欄位缺失 | | 105 | `InputUnsupportedMedia` | **415** Unsupported Media Type | `Content-Type` 不支援 | | 106 | `InputPayloadTooLarge` | **413** Payload Too Large | HTTP body 過大 | ### B. DB(2xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 201 | `DBError` | **500** Internal Server Error | 非預期 DB 故障 | | 202 | `DBDataConvert` | **422** | 可修正的資料轉換問題 | | 203 | `DBDuplicate` | **409** Conflict | 唯一鍵 / 重複寫入 | | 204 | `DBUnavailable` | **503** Service Unavailable | DB 暫時不可用(可重試) | ### C. Resource(3xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 301 | `ResNotFound` | **404** Not Found | 資源不存在 | | 302 | `ResInvalidFormat` | **422** | 資源表示 / Schema 不符 | | 303 | `ResAlreadyExist` | **409** | 已存在 | | 304 | `ResInsufficient` | **400** | 數量 / 容量不足(客戶端可調參數) | | 305 | `ResInsufficientPerm` | **403** Forbidden | 對該資源無權限 | | 306 | `ResInvalidMeasureID` | **400** | ID 格式不合法 | | 307 | `ResExpired` | **410** Gone | 已過期 | | 308 | `ResMigrated` | **410** | 已遷移(可在 Gateway 加 `Location`) | | 309 | `ResInvalidState` | **409** | 狀態機不允許此操作 | | 310 | `ResInsufficientQuota` | **429** Too Many Requests | 配額 / 額度不足 | | 311 | `ResMultiOwner` | **409** | 所有權衝突 | | 312 | `ResPreconditionFailed` | **412** Precondition Failed | ETag / 版本前置條件失敗 | | 313 | `ResLocked` | **423** Locked | 資源被鎖定 | ### D. gRPC 轉換(4xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 400 | `CatGRPC` | **依 Detail** | Detail 存標準 `codes.Code`(0–16),見下方子表 | `CatGRPC` 子映射(與 [Google API 設計指南](https://cloud.google.com/apis/design/errors) 對齊): | gRPC Code | HTTP | |-----------|:----:| | `InvalidArgument`, `OutOfRange`, `FailedPrecondition` | 400 | | `NotFound` | 404 | | `AlreadyExists`, `Aborted` | 409 | | `PermissionDenied` | 403 | | `Unauthenticated` | 401 | | `ResourceExhausted` | 429 | | `DeadlineExceeded` | 504 | | `Unavailable` | 503 | | `Unimplemented` | 501 | | `Canceled` | 408 | | `Internal`, `Unknown`, `DataLoss` | 500 | ### E. Auth(5xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 501 | `AuthUnauthorized` | **401** Unauthorized | 未提供或無效憑證 | | 502 | `AuthExpired` | **401** | Token / 會話過期 | | 503 | `AuthInvalidPosixTime` | **401** | 時戳異常導致驗簽失敗 | | 504 | `AuthSigPayloadMismatch` | **401** | 簽名與 payload 不符 | | 505 | `AuthForbidden` | **403** | 已驗證但無操作權限 | | 506 | `AuthMethodNotAllowed` | **405** Method Not Allowed | HTTP method 不允許 | ### F. System(6xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 601 | `SysInternal` | **500** | 系統內部錯誤 | | 602 | `SysMaintain` | **503** Service Unavailable | 維護 / 停機 | | 603 | `SysTimeout` | **504** Gateway Timeout | 處理或下游逾時 | | 604 | `SysTooManyRequest` | **429** | 全局限流 | | 605 | `SysNotImplemented` | **501** Not Implemented | 功能未上線 / 開關關閉 | | 606 | `SysClientTimeout` | **408** Request Timeout | 客戶端未完成請求(與 603/504 區分) | ### G. PubSub(7xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 701 | `PSuPublish` | **502** Bad Gateway | 發佈到匯流排失敗 | | 702 | `PSuConsume` | **502** | 消費失敗 | | 703 | `PSuTooLarge` | **413** Payload Too Large | 訊息過大 | ### H. Service(8xx) | Category | 常數 | HTTP | 說明 | |:--------:|------|:----:|------| | 801 | `SvcInternal` | **500** | 服務邏輯內錯 | | 802 | `SvcThirdParty` | **502** | 呼叫外部 API 失敗 | | 803 | `SvcHTTP400` | **400** | 明確要回 400 的業務情境 | | 804 | `SvcMaintenance` | **503** | 模組級維護 | | 805 | `SvcRateLimited` | **429** | 特定下游 / 供應商限流 | ### HTTP 狀態碼使用一覽 本套件目前會回傳的 HTTP 狀態: `200` `400` `401` `403` `404` `405` `408` `409` `410` `412` `413` `415` `422` `423` `429` `500` `501` `502` `503` `504` --- ## HTTP 映射設計評估 ### 整體結論:**合理,可作為 API Gateway 的預設策略** 多數映射符合 REST 慣例與 Google / Microsoft API 設計指南。Category 負責「協定層語意」,Detail 負責「業務細節」,分工清楚。 ### 映射得當(建議維持) | 映射 | 理由 | |------|------| | Input 格式 → 400,語意 → 422 | 區分「語法錯」與「語意錯」,利於客戶端處理 | | DB 重複 → 409 | 標準 REST 做法 | | 資源不存在 → 404 | 標準 | | 過期 / 遷移 → 410 | 比 404 更精確表達「曾存在但不可用」 | | 狀態衝突 / 多所有者 → 409 | 符合狀態機與併發場景 | | 配額 / 限流 → 429 | 與 `Retry-After` 搭配良好 | | 未驗證 → 401,已驗證無權 → 403 | 符合 RFC 9110 | | 下游 / 第三方 / MQ → 502 | Gateway 視角正確 | | 維護 → 503 | 可搭配 `Retry-After` | | 處理逾時 → 504 | 適合 Gateway;區分於客戶端 408 | ### 可商榷(依產品政策調整,非必須改) | 現狀 | 替代方案 | 何時考慮 | |------|----------|----------| | `DBError` → 500 | 使用 **`DBUnavailable` → 503** | 暫時性連線問題應改用 `errb.DBUnavailable()` | | `ResInsufficient` → 400 | **409** 或 **422** | 若語意是「庫存不足導致無法完成訂單」而非「參數錯」 | | `PSuPublish/Consume` → 502 | **503** | 訊息中介明確處於不可用(非單次請求失敗) | | `SysTimeout` → 504 | **500** | 若逾時發生在服務內部、前面沒有 Gateway 代理 | | `AuthExpired` → 401 | 少數團隊用 403 | 401 較符合 OAuth2 / OIDC 慣例,建議維持 401 | | `FailedPrecondition`(gRPC)→ 400 | **412** | 若錯誤來自 `If-Match` / ETag 版本衝突 | ### 先前缺口(已補) | 問題 | 處理 | |------|------| | `CatGRPC`(400)未映射,一律 500 | 已依 Detail 中的 gRPC code 映射 HTTP(見上表) | | 未知 category | 仍 fallback **500**(保守、安全) | --- ### 號段保留建議(擴充用) | 號段 | 用途 | |------|------| | 104–199 | Input 擴充 | | 204–299 | DB 擴充 | | 312–399 | Resource 擴充(400 保留給 `CatGRPC`) | | 506–599 | Auth 擴充 | | 605–699 | System 擴充 | | 704–799 | PubSub 擴充 | | 805–899 | Service 擴充 | | 900–999 | 平台 / 保留 | --- ## gRPC ```go // 服務端:回傳 status return e.GRPCStatus().Err() // 客戶端:還原為 *Error(內建 gRPC 碼請傳入本服務 scope) e, err := errs.FromGRPCError(grpcErr, code.Facade) ``` - 業務 8 碼寫在 message:`[10101007] email invalid` - `GRPCCode()` 依 **Category** 映射標準 gRPC code(不會把 8 碼當成 gRPC code) - Category → gRPC 對照見 `grpc.go` 中 `grpcCodeForCategory` --- ## 日誌(可選,`errlog`) ```go import ( "log/slog" "gateway/internal/library/errlog" ) errlog.Error(ctx, slog.Default(), e, "request failed", "req_id", reqID) // 或只取欄位,餵給 go-zero / zap attrs := errlog.Attrs(e) ``` --- ## Scope 常數 定義於 `code/types.go`,例如:`Facade(10)`(handler parse)、`Auth(28)`、`Member(29)`、`Notification(30)` … `GearAssetMgr(27)`。 新增服務時在該檔登記,避免號段衝突。 --- ## 遷移(舊版 API) | 舊 | 新 | |----|-----| | `errs.Scope = code.Facade` | `var errb = errs.For(code.Facade)` | | `errs.ResNotFoundError("x")` | `errb.ResNotFound("x")` | | `e.Wrap(err)` | `e.WithCause(err)` | | `ResNotFoundErrorL` / `WithLog` | 先建構 error,再 `errlog.Error` | --- ## 測試 ```bash go test ./internal/library/errors/... ```