# 結構化錯誤碼

套件路徑：`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/...
```
