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

304 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 結構化錯誤碼
套件路徑:`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/...
```