template-monorepo/internal/library/errors/README.md

304 lines
10 KiB
Markdown
Raw Normal View History

2026-05-19 11:00:28 +00:00
# 結構化錯誤碼
套件路徑:`gateway/internal/library/errors`import 建議別名 `errs`
## 錯誤碼格式
8 碼 **SSCCCDDD**(十進位,左側補零顯示):
| 段 | 名稱 | 範圍 | 說明 |
|----|------|------|------|
| SS | Scope | 0099 | 服務 / 模組(見 `code/types.go` 常數) |
| CCC | Category | 000999 | 錯誤類別,**決定 HTTP / gRPC 映射** |
| DDD | Detail | 000999 | 業務細節,不影響 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. Input1xx
| 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. DB2xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 201 | `DBError` | **500** Internal Server Error | 非預期 DB 故障 |
| 202 | `DBDataConvert` | **422** | 可修正的資料轉換問題 |
| 203 | `DBDuplicate` | **409** Conflict | 唯一鍵 / 重複寫入 |
| 204 | `DBUnavailable` | **503** Service Unavailable | DB 暫時不可用(可重試) |
### C. Resource3xx
| 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`016見下方子表 |
`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. Auth5xx
| 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. System6xx
| 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. PubSub7xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 701 | `PSuPublish` | **502** Bad Gateway | 發佈到匯流排失敗 |
| 702 | `PSuConsume` | **502** | 消費失敗 |
| 703 | `PSuTooLarge` | **413** Payload Too Large | 訊息過大 |
### H. Service8xx
| 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**(保守、安全) |
---
### 號段保留建議(擴充用)
| 號段 | 用途 |
|------|------|
| 104199 | Input 擴充 |
| 204299 | DB 擴充 |
| 312399 | Resource 擴充400 保留給 `CatGRPC` |
| 506599 | Auth 擴充 |
| 605699 | System 擴充 |
| 704799 | PubSub 擴充 |
| 805899 | Service 擴充 |
| 900999 | 平台 / 保留 |
---
## 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)`、`LocalAPI(11)`、`GearAuditLog(12)` … `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/...
```