feat: add project-owned error catalogs and HTTP 200 failure contracts
This commit is contained in:
133
errors/README.md
Normal file
133
errors/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 统一错误目录
|
||||
|
||||
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
|
||||
|
||||
## 基本规则
|
||||
|
||||
- `key` / `Definitions` 的 map key 是唯一逻辑标识,例如 `USERNAME_INVALID`、`user.notFound`。API 新目录允许项目自己的标识命名;旧 `error (...)` 声明保持原有大写标识规则。
|
||||
- `code` 是对外业务码,支持字符串、整数,同一个码可以由多个不同标识使用。JSON/YAML 和 `.api` 读取整数时保留 int64 精度。
|
||||
- `DefaultStatus` / `defaultStatus` 只为目录中未写状态的条目提供默认值,省略时为 400。项目可设为 200;单项 `Status` / `status` 优先。
|
||||
- 目录条目允许有响应体的最终 HTTP 状态:200..599,排除 204、205、304。成功输出与错误输出分开;HTTP 200 并不把错误变成成功。
|
||||
- 数字码类型与范围遵守选中响应的 `codeType`(string/int32/int64;integer 是旧别名)。不同路由可以选择不同类型的公共响应与目录。
|
||||
- 新目录声明完整公开提示;每条错误的业务码和提示优先于统一 `errorCode` / `errorMessage` 兜底。旧的同标识 `codes` / `messages` 若与目录冲突,初始化或契约编译报错;相同值允许保留,便于逐步迁移。
|
||||
|
||||
## 手写项目
|
||||
|
||||
```go
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
catalog := omnierrors.Catalog{
|
||||
DefaultStatus: 200,
|
||||
Definitions: map[string]omnierrors.Definition{
|
||||
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"},
|
||||
"FORBIDDEN": {Status: 403, Code: 403001, Message: "没有权限"},
|
||||
},
|
||||
}
|
||||
app := omni.New(
|
||||
omni.WithErrors(catalog),
|
||||
omni.WithResponse(response.Config{
|
||||
CodeType: "int32", SuccessCode: 0, ErrorCode: 9999,
|
||||
MessageField: "msg", DataMode: "always",
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Service 返回 `omnierrors.ByCode("USERNAME_INVALID")`,或 `.WithCause(err)`。业务无需接收 App/HTTP Context。目录属于 App,初始化时校验并复制;后续修改原 map 不影响正在服务的应用。配置快照也不暴露内部 map。
|
||||
|
||||
`NewRegistry(catalog)` / `MustRegistry(catalog)` 可在 CLI/任务消费者等非 HTTP 入口使用;`registry.Resolve(err)` 返回独立的 `*APIError`,包含逻辑 `Code`、对外 `BusinessCode`、Status、Message 与内部 Cause。每次调用都创建新对象;不要自行将一个可变错误对象保存为共享单例再调用 WithCause。
|
||||
|
||||
原有 `New(status, key, message)` 等明确错误也能被同标识目录条目覆盖;未在目录中的明确错误沿用原状态与提示。未配置目录时,原有行为保持。Go 目录允许 `Definition.Code == nil` 以继续使用旧 Codes/errorCode 映射;新 `.api errorCatalog` 要求显式 code。
|
||||
|
||||
## 文件配置
|
||||
|
||||
下面是生成项目的 `etc/config.yaml` 结构;直接 `config.LoadFile` 加载框架配置时去掉外面的 `server`。手写 main 需显式将加载结果传入 `WithConfig`。
|
||||
|
||||
```yaml
|
||||
server:
|
||||
errors:
|
||||
defaultStatus: 200
|
||||
definitions:
|
||||
USERNAME_INVALID:
|
||||
code: 400002
|
||||
message: 用户名格式不正确
|
||||
FORBIDDEN:
|
||||
status: 403
|
||||
code: 403001
|
||||
message: 没有权限
|
||||
response:
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: msg
|
||||
dataMode: always
|
||||
```
|
||||
|
||||
重复 JSON/YAML key、未知字段、非法状态、非整数数值及类型/范围错误会拒绝加载。目录不支持运行时热更新;修改后重新启动,避免请求与文档在运行期间漂移。
|
||||
|
||||
## .api:定义一次,生成调用与文档
|
||||
|
||||
完整示例见 [errors.api](../examples/contracts/errors.api)。语法:
|
||||
|
||||
```text
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
|
||||
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
目录可放进公共 `.api` 再 import;也可以拆成多个命名目录,各自设置默认状态。生成器合并定义时检查目录名、函数名、逻辑标识冲突。`Catalog` 是生成包的保留函数名。目录对象的键是生成的 UpperCamelCase 函数名,条目的 `key` 是运行时逻辑标识,二者用途不同。
|
||||
|
||||
在 `@server` 的 `errors` 或路由 `@errors` 中引用函数名,响应仍通过原有 `response` 声明选择:
|
||||
|
||||
```text
|
||||
@server (
|
||||
response: CommonResponse
|
||||
errors: UsernameInvalid, PasswordInvalid
|
||||
)
|
||||
service account-api {
|
||||
@handler token
|
||||
post /token returns (TokenResponse)
|
||||
}
|
||||
```
|
||||
|
||||
生成结果包括:
|
||||
|
||||
- `apierrors.UsernameInvalid()` 等无参数函数,每次返回独立错误,支持 WithCause。
|
||||
- `apierrors.CodeUsernameInvalid` 等逻辑标识常量。
|
||||
- `apierrors.Catalog()`:整个契约的可复制运行时目录,方便手写路由复用。
|
||||
- 自动绑定到生成路由的目录:只包含该路由通过 `@errors` / `@server errors` 声明的错误,在 App 全局中间件之前生效,支持认证提前返回和缓冲克隆。
|
||||
- OpenAPI 的错误响应与示例。同一 HTTP 200 下用 `anyOf` 同时描述成功 DTO 和错误体;多个错误共用数字码时保持多个示例;raw 成功体与失败公共体也分开描述。
|
||||
|
||||
业务直接 `return TokenResponse{}, apierrors.UsernameInvalid()`。也可以返回 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`;使用标识引用时,当前 App/路由目录必须包含该错误。不要把“函数已生成”视作“所有路由都已声明这个错误”:新增实际错误后仍应补相应 `@errors`,保持文档准确。
|
||||
|
||||
生成路由的目录和响应由 `.api` 管理;YAML 或 App 默认策略不覆盖它。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 并选择兼容的 `WithResponse`。新项目和重复生成都会自动绑定路由目录,无需手动修改 main;已有手写文件保持不动。
|
||||
|
||||
## 特殊协议与失败信息
|
||||
|
||||
`response.WithErrors(catalog, encoder)` / `MustWithErrors` 可绑定路由专用策略,再通过 `HandleWithResponse` 注册。内置模板校验目录码类型与旧映射;自定义 Encoder 自行决定最终 HTTP 状态、媒体类型和字段结构,可将失败转换成第三方要求的文本/XML 回执。一个 Encoder 作用域不叠加不同目录;外层空目录或相同目录不会覆盖已有目录。
|
||||
|
||||
Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公开 `Message`、原始 `Err` 及 `Failed`。使用 `result.IsError()` 判断失败,不要仅判断 `Status >= 400`。`RawJSON` 仅让成功结果保持未包装,HTTP 200 的失败仍使用选定错误策略。`c.Text` / `c.XML` / `c.JSON` 等直接输出及已提交的标准 Handler 响应不会被目录重新包装。
|
||||
|
||||
启用访问日志时,记录最终写出的 HTTP 状态,并保留 `failed`、`errorKey` 和可用的 `businessCode`。内置指标将已处理的业务失败计入 Errors;链路记录保留 HTTP 200 和业务失败标记。标准中间件桥接和本地/共享幂等重放保留失败标识;已有缓存条目没有新增元数据时无法追溯识别业务失败,等待正常过期即可。
|
||||
|
||||
未知的 ByCode 标识、普通 error、panic 默认走 `INTERNAL_ERROR`,公开兜底不暴露 cause。项目可显式配置 INTERNAL_ERROR 条目,但目录 DefaultStatus 本身不会改变系统兜底。启用访问日志后,可通过原始 error 追查未知标识和内部原因。
|
||||
|
||||
## 升级
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go test ./...
|
||||
```
|
||||
|
||||
先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.10.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。
|
||||
217
errors/catalog.go
Normal file
217
errors/catalog.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package apierrors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Definition belongs to the project. Code is the public string/integer code;
|
||||
// the Catalog map key is the distinct logical identifier used by ByCode.
|
||||
// Nil Code retains legacy response code mapping (useful when migrating).
|
||||
type Definition struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
DefaultStatus int `json:"defaultStatus,omitempty"`
|
||||
Definitions map[string]Definition `json:"definitions"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON preserves int64 precision and rejects ambiguous duplicate keys.
|
||||
func (c *Catalog) UnmarshalJSON(data []byte) error {
|
||||
type plain Catalog
|
||||
value := plain(c.Clone())
|
||||
if err := DecodeCatalogJSON(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = Catalog(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeCatalogJSON strictly decodes catalog metadata without float conversion.
|
||||
// It is also used by the API compiler so runtime and contracts agree.
|
||||
func DecodeCatalogJSON(data []byte, dst any) error {
|
||||
scan := json.NewDecoder(bytes.NewReader(data))
|
||||
scan.UseNumber()
|
||||
var read func() error
|
||||
read = func() error {
|
||||
token, err := scan.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d, ok := token.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for scan.More() {
|
||||
k, err := scan.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := k.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("errors: object key required")
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("errors: duplicate key %q", key)
|
||||
}
|
||||
seen[key] = true
|
||||
if err := read(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for scan.More() {
|
||||
if err := read(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("errors: invalid JSON delimiter")
|
||||
}
|
||||
_, err = scan.Token()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := read(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := scan.Token(); err != io.EOF {
|
||||
return fmt.Errorf("errors: one JSON value required")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func (c Catalog) Clone() Catalog {
|
||||
out := c
|
||||
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
||||
for key, d := range c.Definitions {
|
||||
out.Definitions[key] = d
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Registry is immutable after construction and may be shared across requests.
|
||||
// It has no process-global state. Every Resolve call returns a fresh error.
|
||||
type Registry struct{ catalog Catalog }
|
||||
|
||||
func NewRegistry(c Catalog) (*Registry, error) {
|
||||
c = c.Clone()
|
||||
if c.DefaultStatus == 0 {
|
||||
c.DefaultStatus = 400
|
||||
}
|
||||
if !ValidErrorStatus(c.DefaultStatus) {
|
||||
return nil, fmt.Errorf("errors: invalid defaultStatus %d", c.DefaultStatus)
|
||||
}
|
||||
for key, d := range c.Definitions {
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
|
||||
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
||||
}
|
||||
if d.Status == 0 {
|
||||
d.Status = c.DefaultStatus
|
||||
}
|
||||
if !ValidErrorStatus(d.Status) {
|
||||
return nil, fmt.Errorf("errors: %s has invalid status %d", key, d.Status)
|
||||
}
|
||||
if strings.TrimSpace(d.Message) == "" {
|
||||
return nil, fmt.Errorf("errors: %s requires a public message", key)
|
||||
}
|
||||
if d.Code != nil {
|
||||
code, err := normalizeCatalogCode(d.Code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errors: %s code: %w", key, err)
|
||||
}
|
||||
d.Code = code
|
||||
}
|
||||
c.Definitions[key] = d
|
||||
}
|
||||
return &Registry{catalog: c}, nil
|
||||
}
|
||||
|
||||
// ValidErrorStatus permits final responses with a JSON body, including HTTP 200.
|
||||
func ValidErrorStatus(status int) bool {
|
||||
return status >= 200 && status <= 599 && status != 204 && status != 205 && status != 304
|
||||
}
|
||||
|
||||
func MustRegistry(c Catalog) *Registry {
|
||||
r, err := NewRegistry(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
func (c Catalog) Validate() error { _, err := NewRegistry(c); return err }
|
||||
func (r *Registry) Catalog() Catalog {
|
||||
if r == nil {
|
||||
return Catalog{}
|
||||
}
|
||||
return r.catalog.Clone()
|
||||
}
|
||||
|
||||
// ByCode identifies an error without coupling business code to its HTTP policy.
|
||||
// Resolution occurs in the application's/request's registry. An unknown reference
|
||||
// becomes an internal error; its identifier and cause stay available to logs.
|
||||
func ByCode(key string) *APIError { return &APIError{Code: key, reference: true} }
|
||||
|
||||
func (r *Registry) Resolve(err error) *APIError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var source *APIError
|
||||
if !stdErrors.As(err, &source) || source == nil {
|
||||
source = Internal(err)
|
||||
}
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[source.Code]; ok {
|
||||
return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: source.Cause}
|
||||
}
|
||||
}
|
||||
if source.reference {
|
||||
source = Internal(err)
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[source.Code]; ok {
|
||||
return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
copy := *source
|
||||
return ©
|
||||
}
|
||||
|
||||
func normalizeCatalogCode(value any) (any, error) {
|
||||
if v, ok := value.(string); ok {
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("must be nonempty")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
if v, ok := value.(json.Number); ok {
|
||||
n, err := v.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("must be an exact int64")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
v := reflect.ValueOf(value)
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int(), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if v.Uint() <= math.MaxInt64 {
|
||||
return int64(v.Uint()), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("must be a string or signed 64-bit integer")
|
||||
}
|
||||
100
errors/catalog_test.go
Normal file
100
errors/catalog_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package apierrors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func TestCatalogIsolationDefaultsAndCauses(t *testing.T) {
|
||||
catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"FIRST": {Code: 400002, Message: "first"}, "SECOND": {Code: 400002, Message: "second"}, "DENIED": {Status: 403, Code: "denied", Message: "denied"},
|
||||
}}
|
||||
r, err := apierrors.NewRegistry(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog.Definitions["FIRST"] = apierrors.Definition{Message: "changed"}
|
||||
r.Catalog().Definitions["FIRST"] = apierrors.Definition{Message: "also changed"}
|
||||
cause := errors.New("private secret")
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ref := apierrors.ByCode("FIRST").WithCause(cause)
|
||||
e := r.Resolve(fmt.Errorf("wrapped: %w", ref))
|
||||
if e.Status != 200 || e.BusinessCode != int64(400002) || e.Message != "first" || !errors.Is(e, cause) {
|
||||
t.Error(e)
|
||||
}
|
||||
e.WithCause(errors.New("different"))
|
||||
if ref.Cause != cause || r.Resolve(apierrors.ByCode("FIRST")).Cause != nil {
|
||||
t.Error("shared state")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := r.Resolve(apierrors.ByCode("SECOND")); got.Message != "second" || got.BusinessCode != int64(400002) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := r.Resolve(apierrors.ByCode("DENIED")); got.Status != 403 || got.BusinessCode != "denied" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
other := apierrors.MustRegistry(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"FIRST": {Status: 409, Code: 9, Message: "other"}}})
|
||||
if got := other.Resolve(apierrors.ByCode("FIRST")); got.Status != 409 || got.Message != "other" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := r.Resolve(apierrors.ByCode("MISSING").WithCause(cause)); got.Status != 500 || got.Code != "INTERNAL_ERROR" || got.Message == cause.Error() || !errors.Is(got, cause) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := apierrors.From(apierrors.ByCode("MISSING")); got.Status != 500 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
legacy := apierrors.New(422, "OLD", "legacy")
|
||||
if got := r.Resolve(legacy); got == legacy || got.Status != 422 || got.Message != "legacy" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogStrictJSONAndValues(t *testing.T) {
|
||||
raw := `{"defaultStatus":200,"definitions":{"LARGE":{"code":9223372036854775807,"message":"large"}}}`
|
||||
var c apierrors.Catalog
|
||||
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := apierrors.MustRegistry(c).Resolve(apierrors.ByCode("LARGE"))
|
||||
if e.BusinessCode != int64(math.MaxInt64) {
|
||||
t.Fatal(e.BusinessCode)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"definitions":{"A":{"code":1,"message":"a"},"A":{"code":2,"message":"b"}}}`,
|
||||
`{"definitions":{"A":{"code":1,"code":2,"message":"a"}}}`,
|
||||
`{"definitions":{"A":{"code":1,"message":"a","typo":true}}}`,
|
||||
`{"defaultStatus":204,"definitions":{}}`,
|
||||
`{"definitions":{"A":{"code":1.5,"message":"a"}}}`,
|
||||
`{"definitions":{"A":{"code":9223372036854775808,"message":"a"}}}`,
|
||||
} {
|
||||
var invalid apierrors.Catalog
|
||||
err := json.Unmarshal([]byte(raw), &invalid)
|
||||
if err == nil {
|
||||
err = invalid.Validate()
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("accepted %s", raw)
|
||||
}
|
||||
}
|
||||
for _, status := range []int{0, 99, 199, 204, 205, 304, 600} {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: status, Code: "a", Message: "a"}}}
|
||||
if status == 0 {
|
||||
continue
|
||||
}
|
||||
if c.Validate() == nil {
|
||||
t.Fatal(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
// APIError is the public error shape used by handlers and middleware.
|
||||
// Cause is retained for logging and unwrapping, but is never serialized.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
Cause error
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
Cause error
|
||||
BusinessCode any // Optional public code resolved from a project catalog.
|
||||
reference bool
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
@@ -68,6 +70,9 @@ func From(err error) *APIError {
|
||||
}
|
||||
var apiErr *APIError
|
||||
if stdErrors.As(err, &apiErr) && apiErr != nil {
|
||||
if apiErr.reference {
|
||||
return Internal(err)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
return Internal(err)
|
||||
|
||||
Reference in New Issue
Block a user