2026-05-19 11:00:28 +00:00
|
|
|
|
# 結構化錯誤碼
|
|
|
|
|
|
|
|
|
|
|
|
套件路徑:`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 常數
|
|
|
|
|
|
|
2026-05-21 06:45:35 +00:00
|
|
|
|
定義於 `code/types.go`,例如:`Facade(10)`(handler parse)、`Auth(28)`、`Member(29)`、`Notification(30)` … `GearAssetMgr(27)`。
|
2026-05-19 11:00:28 +00:00
|
|
|
|
新增服務時在該檔登記,避免號段衝突。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 遷移(舊版 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/...
|
|
|
|
|
|
```
|