Add a shared error-code catalog so failures can be traced by stable codes.
Services can resolve legacy aliases and expose the catalog over CLI and HTTP instead of ad-hoc error strings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
jobs:
|
||||||
|
vet-and-test:
|
||||||
|
runs-on: nightly
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: go vet ./... && go test -count=1 ./...
|
||||||
|
docker-checkout-hostgateway:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
options: --add-host gitea:host-gateway
|
||||||
|
env:
|
||||||
|
GITHUB_SERVER_URL: http://gitea:3000
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*.test
|
||||||
|
/bin/
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Error-Code-System
|
||||||
|
|
||||||
|
跨系统共用的错误码目录、进程内错误类型,以及可独立部署的查询服务。目标是让失败能够按稳定码定位,而不是依赖各服务自己拼接的错误文本。
|
||||||
|
|
||||||
|
本仓库是该模块的源码位置。Go 模块路径为 `error-code-system`。不依赖 Agent-Native、Agent-Squad 或任何第三方模块。
|
||||||
|
|
||||||
|
## 分层
|
||||||
|
|
||||||
|
| 包 | 职责 | 依赖边界 |
|
||||||
|
|---|---|---|
|
||||||
|
| errcode | 码格式、定义、注册表、`E` 错误类型 | 只依赖标准库 |
|
||||||
|
| domains | sys / agent / squad 种子码;Go 为权威,JSON 为导出快照 | 只依赖 errcode |
|
||||||
|
| httpx | 统一错误信封与 `X-Trace-Id` | 只依赖 errcode |
|
||||||
|
| server | HTTP API,默认 `:8091` | 组合根之一 |
|
||||||
|
| cli | `errcode` 命令 | 组合根之一 |
|
||||||
|
| conformance | 种子目录治理验收(格式、唯一、别名、未知码回落) | 只用公开 API |
|
||||||
|
|
||||||
|
## 码
|
||||||
|
|
||||||
|
规范码形如 `agent.provider_unavailable`。历史字符串(`provider_unavailable`、`TIMEOUT`、`MISSING_TARGET`)登记为别名,解析后回到规范码。未知码 fail-closed,回落 `sys.unknown_code`(HTTP 500,禁止当成功,禁止无条件重放)。
|
||||||
|
|
||||||
|
每条码带有 HTTP 状态、严重级别、默认重试策略、副作用(`none` / `possible` / `unknown`)和中文处置建议。副作用语义与 agent-native 契约一致:`possible` 与 `unknown` 不得无条件重放。
|
||||||
|
|
||||||
|
映射表:
|
||||||
|
|
||||||
|
- [码格式](docs/code-format.md)
|
||||||
|
- [注册治理](docs/governance.md)
|
||||||
|
- [agent-native](docs/agent-native-mapping.md)
|
||||||
|
- [agent-squad](docs/agent-squad-mapping.md)
|
||||||
|
|
||||||
|
## 进程内用法
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := errcode.Wrap(cause, errcode.AgentProviderUnavailable, "模型不可达", "model", "glm-4")
|
||||||
|
def := errcode.Default.Resolve(err.ErrorCode()) // 需先导入 error-code-system/domains
|
||||||
|
```
|
||||||
|
|
||||||
|
`E` 实现 `Unwrap`,可用 `errors.Is` 按码匹配,也可 JSON 传到另一个进程。日志系统若看到 `ErrorCode()` / `ErrorFields()` 两个方法,即可把码和字段写入日志,而无需依赖本模块。
|
||||||
|
|
||||||
|
## HTTP 信封
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error":{"code":"agent.provider_unavailable","message":"模型提供方不可用","reason":"模型不可达","trace_id":"..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`X-Trace-Id` 由调用方传入时原样回显;缺失或含空白等非法字符时由中间件生成。用这个标识到日志系统检索同一次请求。
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
| 方法/路径 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /v1/health` | 健康检查 |
|
||||||
|
| `GET /v1/codes?domain=&q=` | 列表;`q` 匹配码、摘要、建议、别名 |
|
||||||
|
| `GET /v1/codes/{code}` | 规范码或别名;未知返回 `sys.not_found` |
|
||||||
|
| `POST /v1/validate` | 正文 `{"code":"..."}` 或 `{"codes":[...]}`,未知回落并标 `known:false` |
|
||||||
|
| `GET /v1/export?domain=` | 导出 JSON |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o bin/errcode .
|
||||||
|
./bin/errcode list --domain agent
|
||||||
|
./bin/errcode show TIMEOUT
|
||||||
|
./bin/errcode search 交付物
|
||||||
|
./bin/errcode validate provider_unavailable
|
||||||
|
./bin/errcode export --domain squad
|
||||||
|
./bin/errcode serve --addr :8091
|
||||||
|
```
|
||||||
|
|
||||||
|
`validate` 在码未登记时退出码为 1。
|
||||||
|
|
||||||
|
## 自定义域
|
||||||
|
|
||||||
|
```go
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
if err := reg.LoadJSON(data); err != nil { /* 本批回滚 */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./...
|
||||||
|
go test -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
改种子码后重写导出快照:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WRITE_CATALOG=1 go test ./domains -run TestWriteCatalog -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖本仓库
|
||||||
|
|
||||||
|
```
|
||||||
|
require error-code-system v0.0.0
|
||||||
|
|
||||||
|
replace error-code-system => ../Error-Code-System
|
||||||
|
```
|
||||||
|
|
||||||
|
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git`
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
// Package cli 实现 errcode 命令:list / show / search / validate / export / serve。
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"error-code-system/errcode"
|
||||||
|
"error-code-system/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main 执行命令行并返回进程退出码。
|
||||||
|
func Main(args []string) int {
|
||||||
|
return Run(args, os.Stdout, os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 执行命令。stdout 写结果,stderr 写用法与错误。
|
||||||
|
func Run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(stderr, usage)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
return cmdList(args[1:], stdout, stderr)
|
||||||
|
case "show":
|
||||||
|
return cmdShow(args[1:], stdout, stderr)
|
||||||
|
case "search":
|
||||||
|
return cmdSearch(args[1:], stdout, stderr)
|
||||||
|
case "validate":
|
||||||
|
return cmdValidate(args[1:], stdout, stderr)
|
||||||
|
case "export":
|
||||||
|
return cmdExport(args[1:], stdout, stderr)
|
||||||
|
case "serve":
|
||||||
|
return cmdServe(args[1:], stderr)
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
fmt.Fprintln(stdout, usage)
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(stderr, "未知命令 %q\n%s\n", args[0], usage)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = `用法:
|
||||||
|
errcode list [--domain 域] [--json]
|
||||||
|
errcode show <码或别名>
|
||||||
|
errcode search <关键词>
|
||||||
|
errcode validate <码或别名>
|
||||||
|
errcode export [--domain 域]
|
||||||
|
errcode serve [--addr :8091]`
|
||||||
|
|
||||||
|
func registry() *errcode.Registry { return errcode.Default }
|
||||||
|
|
||||||
|
func cmdList(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
domain := fs.String("domain", "", "只列出该域")
|
||||||
|
asJSON := fs.Bool("json", false, "以 JSON 输出")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
defs := registry().List(*domain)
|
||||||
|
if *asJSON {
|
||||||
|
enc := json.NewEncoder(stdout)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(defs); err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, def := range defs {
|
||||||
|
fmt.Fprintf(stdout, "%s\t%d\t%s\t%s\n", def.Code, def.HTTP, def.Retry, def.Summary)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdShow(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) != 1 {
|
||||||
|
fmt.Fprintln(stderr, "用法: errcode show <码或别名>")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
def, ok := registry().Lookup(args[0])
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(stderr, "未登记: %s(回落 %s)\n", args[0], errcode.SysUnknownCode)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(stdout)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(def); err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdSearch(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(stderr, "用法: errcode search <关键词>")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
q := strings.Join(args, " ")
|
||||||
|
for _, def := range registry().Search(q) {
|
||||||
|
fmt.Fprintf(stdout, "%s\t%s\n", def.Code, def.Summary)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdValidate(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) != 1 {
|
||||||
|
fmt.Fprintln(stderr, "用法: errcode validate <码或别名>")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
def, ok := registry().Lookup(args[0])
|
||||||
|
resolved := def
|
||||||
|
if !ok {
|
||||||
|
resolved = registry().Resolve(args[0])
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "input=%s known=%t resolved=%s\n", args[0], ok, resolved.Code)
|
||||||
|
if !ok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdExport(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
domain := fs.String("domain", "", "只导出该域")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
if *domain == "" {
|
||||||
|
data, err = registry().ExportJSON()
|
||||||
|
} else {
|
||||||
|
encBuf := &strings.Builder{}
|
||||||
|
enc := json.NewEncoder(encBuf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
err = enc.Encode(registry().List(*domain))
|
||||||
|
data = []byte(encBuf.String())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if _, err := stdout.Write(data); err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdServe(args []string, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
addr := fs.String("addr", server.DefaultAddr, "监听地址")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "errcode serve %s\n", *addr)
|
||||||
|
if err := server.New(registry()).ListenAndServe(*addr); err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"error-code-system/domains"
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLIListShowValidate(t *testing.T) {
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
prev := errcode.Default
|
||||||
|
errcode.Default = reg
|
||||||
|
t.Cleanup(func() { errcode.Default = prev })
|
||||||
|
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
if code := Run([]string{"list", "--domain", "sys"}, &out, &errBuf); code != 0 {
|
||||||
|
t.Fatal(errBuf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "sys.internal") || strings.Contains(out.String(), "agent.") {
|
||||||
|
t.Fatalf("list:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
if code := Run([]string{"show", "TIMEOUT"}, &out, &errBuf); code != 0 {
|
||||||
|
t.Fatal(errBuf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), string(errcode.AgentTimeout)) {
|
||||||
|
t.Fatalf("show:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
if code := Run([]string{"validate", "nope"}, &out, &errBuf); code != 1 {
|
||||||
|
t.Fatalf("validate exit %d %s", code, out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "known=false") || !strings.Contains(out.String(), string(errcode.SysUnknownCode)) {
|
||||||
|
t.Fatalf("validate:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
if code := Run([]string{"search", "角色"}, &out, &errBuf); code != 0 {
|
||||||
|
t.Fatal(errBuf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), string(errcode.SquadRoleNotFound)) {
|
||||||
|
t.Fatalf("search:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package conformance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"error-code-system/domains"
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSeedCatalog(t *testing.T) {
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
|
||||||
|
seen := map[errcode.Code]struct{}{}
|
||||||
|
for _, def := range reg.List("") {
|
||||||
|
if !def.Code.Valid() {
|
||||||
|
t.Fatalf("invalid code %s", def.Code)
|
||||||
|
}
|
||||||
|
if err := def.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := seen[def.Code]; ok {
|
||||||
|
t.Fatalf("duplicate %s", def.Code)
|
||||||
|
}
|
||||||
|
seen[def.Code] = struct{}{}
|
||||||
|
if def.SideEffects != errcode.SideEffectNone && def.Retry == errcode.RetryAlways {
|
||||||
|
t.Fatalf("%s: side_effects=%s forbids retry=always", def.Code, def.SideEffects)
|
||||||
|
}
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
got, ok := reg.Lookup(alias)
|
||||||
|
if !ok || got.Code != def.Code {
|
||||||
|
t.Fatalf("alias %s -> %+v ok=%v", alias, got.Code, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy := map[string]errcode.Code{
|
||||||
|
"canceled": errcode.AgentCanceled,
|
||||||
|
"deadline_exceeded": errcode.AgentDeadlineExceeded,
|
||||||
|
"budget_exceeded": errcode.AgentBudgetExceeded,
|
||||||
|
"provider_unavailable": errcode.AgentProviderUnavailable,
|
||||||
|
"provider_rejected": errcode.AgentProviderRejected,
|
||||||
|
"tool_rejected": errcode.AgentToolRejected,
|
||||||
|
"path_outside_workspace": errcode.AgentPathOutsideWorkspace,
|
||||||
|
"missing_capability": errcode.AgentMissingCapability,
|
||||||
|
"missing_deliverable": errcode.AgentMissingDeliverable,
|
||||||
|
"empty_output": errcode.AgentEmptyOutput,
|
||||||
|
"unknown_outcome": errcode.AgentUnknownOutcome,
|
||||||
|
"internal": errcode.AgentInternal,
|
||||||
|
"PROMPT_EMPTY": errcode.AgentPromptEmpty,
|
||||||
|
"UNAUTHORIZED": errcode.AgentUnauthorized,
|
||||||
|
"TIMEOUT": errcode.AgentTimeout,
|
||||||
|
"CANCELLED": errcode.AgentCanceled,
|
||||||
|
"TRANSIENT": errcode.AgentTransient,
|
||||||
|
"MISSING_TARGET": errcode.AgentMissingDeliverable,
|
||||||
|
"RUN_FAILED": errcode.AgentRunFailed,
|
||||||
|
}
|
||||||
|
for alias, want := range legacy {
|
||||||
|
got, ok := reg.Lookup(alias)
|
||||||
|
if !ok || got.Code != want {
|
||||||
|
t.Fatalf("legacy %s => %s, got %+v ok=%v", alias, want, got.Code, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown := reg.Resolve("not-a-code")
|
||||||
|
if unknown.Code != errcode.SysUnknownCode || unknown.Retry != errcode.RetryNever || unknown.SideEffects != errcode.SideEffectUnknown {
|
||||||
|
t.Fatalf("fallback %+v", unknown)
|
||||||
|
}
|
||||||
|
if unknown.HTTP != 500 {
|
||||||
|
t.Fatalf("fallback http %d", unknown.HTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, domain := range []string{"sys", "agent", "squad"} {
|
||||||
|
if len(reg.List(domain)) == 0 {
|
||||||
|
t.Fatalf("domain %s empty", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# agent-native 错误码映射
|
||||||
|
|
||||||
|
本轮不修改 Agent-Native。接入时用下表把现有字符串换成规范码。别名已登记,`Registry.Lookup` / `Resolve` 可直接吃旧值。
|
||||||
|
|
||||||
|
## contract.ErrorCode
|
||||||
|
|
||||||
|
来源:`Agent-Native/contract/contract.go`。
|
||||||
|
|
||||||
|
| 现有码 | 规范码 | HTTP | retry | side_effects | 说明 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| canceled | agent.canceled | 499 | never | possible | 取消必须传播;不可靠取消保持 Unknown,不伪报 Canceled |
|
||||||
|
| deadline_exceeded | agent.deadline_exceeded | 408 | never | possible | 与重试共享 ExecutionID、预算与截止时间 |
|
||||||
|
| budget_exceeded | agent.budget_exceeded | 429 | never | possible | 预算耗尽不是瞬时故障 |
|
||||||
|
| provider_unavailable | agent.provider_unavailable | 503 | transient | none | 传输级有界重试归 adapter |
|
||||||
|
| provider_rejected | agent.provider_rejected | 502 | never | none | 请求被拒绝,修正后再发 |
|
||||||
|
| tool_rejected | agent.tool_rejected | 403 | never | none | 不得自行扩大工具范围 |
|
||||||
|
| path_outside_workspace | agent.path_outside_workspace | 403 | never | none | 路径授权不是 OS 沙箱 |
|
||||||
|
| missing_capability | agent.missing_capability | 400 | never | none | 缺能力即拒绝,不降级 Stub |
|
||||||
|
| missing_deliverable | agent.missing_deliverable | 422 | transient | possible | 可能已有部分写入 |
|
||||||
|
| empty_output | agent.empty_output | 422 | never | none | 空输出不是成功 |
|
||||||
|
| unknown_outcome | agent.unknown_outcome | 500 | never | possible | 禁止降级为 Succeeded 或 Failed |
|
||||||
|
| internal | agent.internal | 500 | never | unknown | 副作用不明,禁止无条件重放 |
|
||||||
|
|
||||||
|
`contract.Error.Retryable` 仍是实例字段。规范码上的 `retry` 只是默认策略:`Retryable=true` 且 `side_effects=none` 才适合 adapter 有界重试。
|
||||||
|
|
||||||
|
## pitransport typed-result-v1
|
||||||
|
|
||||||
|
来源:`Agent-Native/adapters/pitransport/resultv1.go` 的 `allowedErrorCodes`。
|
||||||
|
|
||||||
|
| 现有码 | 规范码 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| PROMPT_EMPTY | agent.prompt_empty | 永久失败,与 `AssessStepFailure` 的 prompt_empty 一致 |
|
||||||
|
| UNAUTHORIZED | agent.unauthorized | 永久失败 |
|
||||||
|
| TIMEOUT | agent.timeout | 可重试 |
|
||||||
|
| CANCELLED | agent.canceled | 并入契约取消码,不另起含义 |
|
||||||
|
| TRANSIENT | agent.transient | 可重试 |
|
||||||
|
| MISSING_TARGET | agent.missing_deliverable | 并入契约交付物缺失 |
|
||||||
|
| RUN_FAILED | agent.run_failed | 永久失败,副作用按 possible |
|
||||||
|
|
||||||
|
`TypedRetryable` 只承认 TIMEOUT / CANCELLED / TRANSIENT。映射后对应 `agent.timeout`、`agent.canceled`、`agent.transient`。其中 `agent.canceled` 的默认策略是 never:实例上的 `retryable` 仍可表达「传输层把取消当成可重试」,调用方必须再看副作用。
|
||||||
|
|
||||||
|
## exec 失败分类
|
||||||
|
|
||||||
|
`exec.FailureClass`(none / transient / permanent)不是错误码。步骤评估继续用 `AssessStepFailure`;对外报告时再选上表中的规范码。永久原因(prompt_empty、unauthorized、治理拦截)对应 never,提供方连接类对应 transient。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# agent-squad 错误码映射
|
||||||
|
|
||||||
|
本轮不修改 Agent-Squad。当前 HTTP 层只有 `{"error":"<文本>"}`(`server.writeErr`),没有稳定码,也没有 trace。接入时把下表套进响应信封,正文理由仍放在 `reason`。
|
||||||
|
|
||||||
|
| 现状 | 规范码 | HTTP | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `role %s not found` | squad.role_not_found | 404 | `handleDeleteRole` 等 |
|
||||||
|
| `blueprint %s not found` | squad.blueprint_not_found | 404 | 蓝图删除 |
|
||||||
|
| Inspect / 黑板找不到运行 | squad.run_not_found | 404 | 磁盘为权威,重启后仍可能存在 |
|
||||||
|
| `goal.text is required` | squad.goal_required | 400 | 创建运行 |
|
||||||
|
| 运行处于 HITL 挂起(`RunStatus.IsHeld`) | squad.hitl_pending | 409 | 用 respond 续跑,不要并发再 Run |
|
||||||
|
| 计划确认 abort / 质检前的计划拒绝 | squad.plan_rejected | 422 | confirmation 挂起点拒绝 |
|
||||||
|
| 未知 backend,fail-closed | squad.unknown_backend | 400 | 禁止回落 stub |
|
||||||
|
| 步骤 `EvStepFailed` | squad.step_failed | 500 | 瞬时失败仍受步骤重试上限约束 |
|
||||||
|
| 质检失败且自动修复预算用尽 | squad.quality_failed | 422 | 可转 quality_review 人工放行 |
|
||||||
|
| `context cancelled` | squad.cancelled | 499 | 步骤保持 pending,可恢复 |
|
||||||
|
| JSON 解码失败 | sys.invalid_argument | 400 | 框架域,不单开小队码 |
|
||||||
|
| 未分类的 500 | sys.internal | 500 | 必须带 trace_id |
|
||||||
|
|
||||||
|
事件名(`step_failed`、`run_failed` 等)保持不变。事件文本里建议追加 `code` 字段,便于和日志系统的 `trace_id` 对齐。脱敏仍沿用现有 `Redact`,日志系统提供同形实现。
|
||||||
|
|
||||||
|
## 与执行错误的边界
|
||||||
|
|
||||||
|
步骤失败的根因优先用 agent 域码(例如 `agent.missing_deliverable`),小队只在编排层再包一层 `squad.step_failed`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
squad.step_failed
|
||||||
|
└─ agent.missing_deliverable
|
||||||
|
└─ 原始执行错误
|
||||||
|
```
|
||||||
|
|
||||||
|
这样查 `squad.step_failed` 能看到编排后果,查 `agent.missing_deliverable` 能看到执行根因。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 错误码格式
|
||||||
|
|
||||||
|
规范码只有一种形式:`domain.mnemonic`。
|
||||||
|
|
||||||
|
| 部分 | 规则 | 例子 |
|
||||||
|
|---|---|---|
|
||||||
|
| domain | 2–32 字符,小写字母开头,仅含小写字母、数字、连字符 | `sys`、`agent`、`squad` |
|
||||||
|
| mnemonic | 2–64 字符,小写字母开头,仅含小写字母、数字、下划线 | `provider_unavailable` |
|
||||||
|
|
||||||
|
正则:`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9_]{1,63}$`
|
||||||
|
|
||||||
|
## 别名
|
||||||
|
|
||||||
|
历史码不是第二套规范码。它们登记为别名,解析后必须回到规范码:
|
||||||
|
|
||||||
|
- agent-native `contract.ErrorCode`:`provider_unavailable` → `agent.provider_unavailable`
|
||||||
|
- pitransport:`PROMPT_EMPTY` → `agent.prompt_empty`,`CANCELLED` → `agent.canceled`,`MISSING_TARGET` → `agent.missing_deliverable`
|
||||||
|
|
||||||
|
别名匹配大小写敏感,格式为 `^[A-Za-z][A-Za-z0-9_-]{1,63}$`。
|
||||||
|
|
||||||
|
## 元数据
|
||||||
|
|
||||||
|
每条码必须同时声明:
|
||||||
|
|
||||||
|
| 字段 | 取值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| http | 100–599 | HTTP 接口的默认状态码。取消使用 499(客户端关闭) |
|
||||||
|
| severity | info / warn / error / fatal | 日志与告警 |
|
||||||
|
| retry | never / transient / always | 默认重试策略。实例仍可另附是否重试 |
|
||||||
|
| side_effects | none / possible / unknown | 对齐 agent-native:possible 与 unknown 禁止无条件重放 |
|
||||||
|
| summary | 非空中文 | 给调用方的稳定摘要 |
|
||||||
|
| hint | 非空中文 | 处置建议,方便值班定位 |
|
||||||
|
|
||||||
|
未知码 `Resolve` 到 `sys.unknown_code`,HTTP 500,retry=never,side_effects=unknown。不得当成成功。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 错误码注册治理
|
||||||
|
|
||||||
|
Go 定义是唯一权威来源,位于 `domains/`。JSON 文件是导出物,供服务查询、CLI 导出和跨语言消费。
|
||||||
|
|
||||||
|
## 新增一条码
|
||||||
|
|
||||||
|
1. 在 `errcode/codes.go` 增加 `Code` 常量。
|
||||||
|
2. 在对应域的 `register*` 中登记 HTTP、严重级别、重试、副作用、中文摘要与处置建议。
|
||||||
|
3. 若承接旧字符串,把它写成 `Aliases`,不要新造一套平行码。
|
||||||
|
4. 运行 `go test ./...`。目录一致性测试会失败,按测试输出重新导出 JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WRITE_CATALOG=1 go test ./domains -run TestWriteCatalog -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 更新 `docs/` 中对应系统的映射表。
|
||||||
|
|
||||||
|
## 不变量
|
||||||
|
|
||||||
|
- 规范码全局唯一;别名不得指向两个码,也不得与规范码同形冲突。
|
||||||
|
- 已发布的规范码只追加别名或澄清文案,不改变码字符串的含义。含义变化时新增码,旧码保留为别名或标记在映射表中。
|
||||||
|
- 副作用为 `possible` 或 `unknown` 的码,默认重试不得是 `always`。
|
||||||
|
- 种子数据在进程启动时登记。登记失败直接 panic,避免带着残缺目录对外服务。
|
||||||
|
|
||||||
|
## 自定义域
|
||||||
|
|
||||||
|
产品可以不改本仓库,在运行期加载 JSON:
|
||||||
|
|
||||||
|
```go
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
data, _ := os.ReadFile("product.json")
|
||||||
|
if err := reg.LoadJSON(data); err != nil { /* 本批回滚 */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 可以是数组,或 `{"codes":[...]}`。本批任一记录不合法时,整批回滚。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import "error-code-system/errcode"
|
||||||
|
|
||||||
|
// registerAgent 对齐 agent-native contract.ErrorCode(12)与 pitransport 历史码(7)。
|
||||||
|
// 副作用与重试语义见 docs/agent-native-mapping.md。
|
||||||
|
func registerAgent(r *errcode.Registry) {
|
||||||
|
must(r, def(errcode.AgentCanceled, 499, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"执行已取消", "确认取消已传播到模型、工具与子进程;不要伪报成功",
|
||||||
|
"canceled", "CANCELLED"))
|
||||||
|
must(r, def(errcode.AgentDeadlineExceeded, 408, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"超过执行截止时间", "预算与截止时间在同一 ExecutionID 内共享,不要换 ID 无条件重放",
|
||||||
|
"deadline_exceeded"))
|
||||||
|
must(r, def(errcode.AgentBudgetExceeded, 429, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"执行预算耗尽", "提高预算或缩小任务后再发起新的执行",
|
||||||
|
"budget_exceeded"))
|
||||||
|
must(r, def(errcode.AgentProviderUnavailable, 503, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectNone,
|
||||||
|
"模型提供方不可用", "视为传输级瞬时故障,只做有界重试",
|
||||||
|
"provider_unavailable"))
|
||||||
|
must(r, def(errcode.AgentProviderRejected, 502, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"模型提供方拒绝请求", "检查模型名、请求体与账号权限,不要当瞬时故障重试",
|
||||||
|
"provider_rejected"))
|
||||||
|
must(r, def(errcode.AgentToolRejected, 403, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"工具调用被拒绝", "工具不在允许范围内;不要自行扩大工具策略",
|
||||||
|
"tool_rejected"))
|
||||||
|
must(r, def(errcode.AgentPathOutsideWorkspace, 403, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"路径越出授权工作区", "只访问 Workspace.Root 之内的路径",
|
||||||
|
"path_outside_workspace"))
|
||||||
|
must(r, def(errcode.AgentMissingCapability, 400, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"后端缺少必需能力", "拒绝执行,不要静默降级到 Stub",
|
||||||
|
"missing_capability"))
|
||||||
|
must(r, def(errcode.AgentMissingDeliverable, 422, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible,
|
||||||
|
"交付物缺失", "确认目标文件未部分写入后再做一次有界重试",
|
||||||
|
"missing_deliverable", "MISSING_TARGET"))
|
||||||
|
must(r, def(errcode.AgentEmptyOutput, 422, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"输出为空", "检查提示词与停止原因,不要把空输出当成成功",
|
||||||
|
"empty_output"))
|
||||||
|
must(r, def(errcode.AgentUnknownOutcome, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"执行结果不确定", "Unknown 不得降级为成功或失败;由调用方依据副作用决定能否重放",
|
||||||
|
"unknown_outcome"))
|
||||||
|
must(r, def(errcode.AgentInternal, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectUnknown,
|
||||||
|
"执行器内部错误", "保留执行日志与 trace_id,不要对未知副作用无条件重放",
|
||||||
|
"internal"))
|
||||||
|
must(r, def(errcode.AgentPromptEmpty, 400, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"提示词为空", "补齐 prompt 后重新发起;对应 pitransport PROMPT_EMPTY",
|
||||||
|
"PROMPT_EMPTY", "prompt_empty"))
|
||||||
|
must(r, def(errcode.AgentUnauthorized, 401, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"执行器鉴权失败", "检查 runner 凭证;对应 pitransport UNAUTHORIZED",
|
||||||
|
"UNAUTHORIZED"))
|
||||||
|
must(r, def(errcode.AgentTimeout, 504, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible,
|
||||||
|
"执行超时", "传输级超时可有界重试;对应 pitransport TIMEOUT",
|
||||||
|
"TIMEOUT"))
|
||||||
|
must(r, def(errcode.AgentTransient, 503, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible,
|
||||||
|
"执行瞬时故障", "仅作有界重试;对应 pitransport TRANSIENT",
|
||||||
|
"TRANSIENT"))
|
||||||
|
must(r, def(errcode.AgentRunFailed, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"执行运行失败", "查看 error_code 以外的运行日志;对应 pitransport RUN_FAILED",
|
||||||
|
"RUN_FAILED"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "agent.budget_exceeded",
|
||||||
|
"http": 429,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "执行预算耗尽",
|
||||||
|
"hint": "提高预算或缩小任务后再发起新的执行",
|
||||||
|
"aliases": [
|
||||||
|
"budget_exceeded"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.canceled",
|
||||||
|
"http": 499,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "执行已取消",
|
||||||
|
"hint": "确认取消已传播到模型、工具与子进程;不要伪报成功",
|
||||||
|
"aliases": [
|
||||||
|
"CANCELLED",
|
||||||
|
"canceled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.deadline_exceeded",
|
||||||
|
"http": 408,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "超过执行截止时间",
|
||||||
|
"hint": "预算与截止时间在同一 ExecutionID 内共享,不要换 ID 无条件重放",
|
||||||
|
"aliases": [
|
||||||
|
"deadline_exceeded"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.empty_output",
|
||||||
|
"http": 422,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "输出为空",
|
||||||
|
"hint": "检查提示词与停止原因,不要把空输出当成成功",
|
||||||
|
"aliases": [
|
||||||
|
"empty_output"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.internal",
|
||||||
|
"http": 500,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "unknown",
|
||||||
|
"summary": "执行器内部错误",
|
||||||
|
"hint": "保留执行日志与 trace_id,不要对未知副作用无条件重放",
|
||||||
|
"aliases": [
|
||||||
|
"internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.missing_capability",
|
||||||
|
"http": 400,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "后端缺少必需能力",
|
||||||
|
"hint": "拒绝执行,不要静默降级到 Stub",
|
||||||
|
"aliases": [
|
||||||
|
"missing_capability"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.missing_deliverable",
|
||||||
|
"http": 422,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "交付物缺失",
|
||||||
|
"hint": "确认目标文件未部分写入后再做一次有界重试",
|
||||||
|
"aliases": [
|
||||||
|
"MISSING_TARGET",
|
||||||
|
"missing_deliverable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.path_outside_workspace",
|
||||||
|
"http": 403,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "路径越出授权工作区",
|
||||||
|
"hint": "只访问 Workspace.Root 之内的路径",
|
||||||
|
"aliases": [
|
||||||
|
"path_outside_workspace"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.prompt_empty",
|
||||||
|
"http": 400,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "提示词为空",
|
||||||
|
"hint": "补齐 prompt 后重新发起;对应 pitransport PROMPT_EMPTY",
|
||||||
|
"aliases": [
|
||||||
|
"PROMPT_EMPTY",
|
||||||
|
"prompt_empty"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.provider_rejected",
|
||||||
|
"http": 502,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "模型提供方拒绝请求",
|
||||||
|
"hint": "检查模型名、请求体与账号权限,不要当瞬时故障重试",
|
||||||
|
"aliases": [
|
||||||
|
"provider_rejected"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.provider_unavailable",
|
||||||
|
"http": 503,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "模型提供方不可用",
|
||||||
|
"hint": "视为传输级瞬时故障,只做有界重试",
|
||||||
|
"aliases": [
|
||||||
|
"provider_unavailable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.run_failed",
|
||||||
|
"http": 500,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "执行运行失败",
|
||||||
|
"hint": "查看 error_code 以外的运行日志;对应 pitransport RUN_FAILED",
|
||||||
|
"aliases": [
|
||||||
|
"RUN_FAILED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.timeout",
|
||||||
|
"http": 504,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "执行超时",
|
||||||
|
"hint": "传输级超时可有界重试;对应 pitransport TIMEOUT",
|
||||||
|
"aliases": [
|
||||||
|
"TIMEOUT"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.tool_rejected",
|
||||||
|
"http": 403,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "工具调用被拒绝",
|
||||||
|
"hint": "工具不在允许范围内;不要自行扩大工具策略",
|
||||||
|
"aliases": [
|
||||||
|
"tool_rejected"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.transient",
|
||||||
|
"http": 503,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "执行瞬时故障",
|
||||||
|
"hint": "仅作有界重试;对应 pitransport TRANSIENT",
|
||||||
|
"aliases": [
|
||||||
|
"TRANSIENT"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.unauthorized",
|
||||||
|
"http": 401,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "执行器鉴权失败",
|
||||||
|
"hint": "检查 runner 凭证;对应 pitransport UNAUTHORIZED",
|
||||||
|
"aliases": [
|
||||||
|
"UNAUTHORIZED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "agent.unknown_outcome",
|
||||||
|
"http": 500,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "执行结果不确定",
|
||||||
|
"hint": "Unknown 不得降级为成功或失败;由调用方依据副作用决定能否重放",
|
||||||
|
"aliases": [
|
||||||
|
"unknown_outcome"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddedCatalogMatchesRegistry(t *testing.T) {
|
||||||
|
r := errcode.NewRegistry()
|
||||||
|
RegisterAll(r)
|
||||||
|
for _, domain := range []string{"sys", "agent", "squad"} {
|
||||||
|
got, err := ExportDomain(r, domain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want, err := CatalogJSON(domain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("%s.json drifted from registry\n got:\n%s\nwant:\n%s", domain, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteCatalog(t *testing.T) {
|
||||||
|
if os.Getenv("WRITE_CATALOG") != "1" {
|
||||||
|
t.Skip("set WRITE_CATALOG=1 to regenerate sys.json agent.json squad.json")
|
||||||
|
}
|
||||||
|
r := errcode.NewRegistry()
|
||||||
|
RegisterAll(r)
|
||||||
|
for _, domain := range []string{"sys", "agent", "squad"} {
|
||||||
|
data, err := ExportDomain(r, domain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(domain+".json", data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// 导出的目录快照。改 Go 定义后用 WRITE_CATALOG=1 重写这些文件。
|
||||||
|
//
|
||||||
|
//go:embed sys.json agent.json squad.json
|
||||||
|
var catalogFS embed.FS
|
||||||
|
|
||||||
|
// CatalogJSON 返回已嵌入的域目录原文。
|
||||||
|
func CatalogJSON(domain string) ([]byte, error) {
|
||||||
|
return catalogFS.ReadFile(domain + ".json")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportDomain 导出单个域的定义 JSON(字典序)。
|
||||||
|
func ExportDomain(r *errcode.Registry, domain string) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
enc := json.NewEncoder(buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(r.List(domain)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainOf 返回定义切片中的域;混合域或空切片返回错误。
|
||||||
|
func DomainOf(defs []errcode.Definition) (string, error) {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return "", fmt.Errorf("domains: empty catalog")
|
||||||
|
}
|
||||||
|
domain := defs[0].Code.Domain()
|
||||||
|
for _, def := range defs[1:] {
|
||||||
|
if def.Code.Domain() != domain {
|
||||||
|
return "", fmt.Errorf("domains: mixed domains %s and %s", domain, def.Code.Domain())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Package domains 把种子错误码登记进注册表。
|
||||||
|
//
|
||||||
|
// Go 定义是权威来源。同目录 JSON 由导出生成,经 go:embed 提供给不编译 Go 的消费方;
|
||||||
|
// conformance 测试保证二者一致。导入本包会填充 errcode.Default。
|
||||||
|
package domains
|
||||||
|
|
||||||
|
import "error-code-system/errcode"
|
||||||
|
|
||||||
|
func init() { RegisterAll(errcode.Default) }
|
||||||
|
|
||||||
|
// RegisterAll 向 r 登记 sys / agent / squad 三域。重复调用会因码冲突失败。
|
||||||
|
func RegisterAll(r *errcode.Registry) {
|
||||||
|
registerSys(r)
|
||||||
|
registerAgent(r)
|
||||||
|
registerSquad(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(r *errcode.Registry, def errcode.Definition) {
|
||||||
|
r.MustRegister(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
func def(code errcode.Code, http int, sev errcode.Severity, retry errcode.Retry, side errcode.SideEffects, summary, hint string, aliases ...string) errcode.Definition {
|
||||||
|
return errcode.Definition{
|
||||||
|
Code: code,
|
||||||
|
HTTP: http,
|
||||||
|
Severity: sev,
|
||||||
|
Retry: retry,
|
||||||
|
SideEffects: side,
|
||||||
|
Summary: summary,
|
||||||
|
Hint: hint,
|
||||||
|
Aliases: aliases,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import "error-code-system/errcode"
|
||||||
|
|
||||||
|
func registerSquad(r *errcode.Registry) {
|
||||||
|
must(r, def(errcode.SquadRoleNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"角色不存在", "确认角色键已注册;未知智能体不得静默回落 stub"))
|
||||||
|
must(r, def(errcode.SquadBlueprintNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"任务模板不存在", "确认蓝图键,或改走自主规划"))
|
||||||
|
must(r, def(errcode.SquadRunNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"运行不存在", "确认 run_id;进程重启后以磁盘状态为准"))
|
||||||
|
must(r, def(errcode.SquadGoalRequired, 400, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"目标文本为空", "提供 goal.text 后再发起运行"))
|
||||||
|
must(r, def(errcode.SquadHitlPending, 409, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"运行挂起等待人工", "读取 pending 请求,回应后再续跑;不要并发重复推进"))
|
||||||
|
must(r, def(errcode.SquadPlanRejected, 422, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"计划被拒绝", "调整目标或编制后重新规划"))
|
||||||
|
must(r, def(errcode.SquadUnknownBackend, 400, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"未知智能体后端", "fail-closed:拒绝执行并留痕,不要回落 stub"))
|
||||||
|
must(r, def(errcode.SquadStepFailed, 500, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible,
|
||||||
|
"步骤执行失败", "区分瞬时/永久失败;非关键步骤可降级,关键步骤终止"))
|
||||||
|
must(r, def(errcode.SquadQualityFailed, 422, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"交付质检未通过", "修复交付物或走人工放行;自动修复预算用尽后不要假装完成"))
|
||||||
|
must(r, def(errcode.SquadCancelled, 499, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"运行已取消", "取消后的步骤保持可恢复,不要写成成功"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "squad.blueprint_not_found",
|
||||||
|
"http": 404,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "任务模板不存在",
|
||||||
|
"hint": "确认蓝图键,或改走自主规划"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.cancelled",
|
||||||
|
"http": 499,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "运行已取消",
|
||||||
|
"hint": "取消后的步骤保持可恢复,不要写成成功"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.goal_required",
|
||||||
|
"http": 400,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "目标文本为空",
|
||||||
|
"hint": "提供 goal.text 后再发起运行"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.hitl_pending",
|
||||||
|
"http": 409,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "运行挂起等待人工",
|
||||||
|
"hint": "读取 pending 请求,回应后再续跑;不要并发重复推进"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.plan_rejected",
|
||||||
|
"http": 422,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "计划被拒绝",
|
||||||
|
"hint": "调整目标或编制后重新规划"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.quality_failed",
|
||||||
|
"http": 422,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "交付质检未通过",
|
||||||
|
"hint": "修复交付物或走人工放行;自动修复预算用尽后不要假装完成"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.role_not_found",
|
||||||
|
"http": 404,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "角色不存在",
|
||||||
|
"hint": "确认角色键已注册;未知智能体不得静默回落 stub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.run_not_found",
|
||||||
|
"http": 404,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "运行不存在",
|
||||||
|
"hint": "确认 run_id;进程重启后以磁盘状态为准"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.step_failed",
|
||||||
|
"http": 500,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "步骤执行失败",
|
||||||
|
"hint": "区分瞬时/永久失败;非关键步骤可降级,关键步骤终止"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "squad.unknown_backend",
|
||||||
|
"http": 400,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "未知智能体后端",
|
||||||
|
"hint": "fail-closed:拒绝执行并留痕,不要回落 stub"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package domains
|
||||||
|
|
||||||
|
import "error-code-system/errcode"
|
||||||
|
|
||||||
|
func registerSys(r *errcode.Registry) {
|
||||||
|
must(r, def(errcode.SysInternal, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectUnknown,
|
||||||
|
"内部错误", "保留 trace_id,按错误日志定位,不要对调用方暴露内部细节"))
|
||||||
|
must(r, def(errcode.SysUnknownCode, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectUnknown,
|
||||||
|
"未登记的错误码", "按失败处理(fail-closed),并把该码补登记后再放行"))
|
||||||
|
must(r, def(errcode.SysInvalidArgument, 400, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"参数不合法", "检查请求字段与类型,修正后重新提交"))
|
||||||
|
must(r, def(errcode.SysUnauthorized, 401, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"未授权", "检查凭证是否缺失、过期或与资源不匹配"))
|
||||||
|
must(r, def(errcode.SysNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone,
|
||||||
|
"资源不存在", "确认标识是否正确,或资源是否已删除"))
|
||||||
|
must(r, def(errcode.SysConflict, 409, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"状态冲突", "刷新当前状态后再决定是否重试,不要盲目重放"))
|
||||||
|
must(r, def(errcode.SysRateLimited, 429, errcode.SeverityWarn, errcode.RetryTransient, errcode.SideEffectNone,
|
||||||
|
"触发限流", "按退避重试,不要立即打满重试"))
|
||||||
|
must(r, def(errcode.SysTimeout, 504, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible,
|
||||||
|
"操作超时", "先确认是否已产生副作用,再做有界重试"))
|
||||||
|
must(r, def(errcode.SysCancelled, 499, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectPossible,
|
||||||
|
"操作已取消", "不要把取消当成成功或普通失败;需要时由调用方重新发起"))
|
||||||
|
must(r, def(errcode.SysUnavailable, 503, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectNone,
|
||||||
|
"依赖不可用", "确认依赖健康后退避重试"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "sys.cancelled",
|
||||||
|
"http": 499,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "操作已取消",
|
||||||
|
"hint": "不要把取消当成成功或普通失败;需要时由调用方重新发起"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.conflict",
|
||||||
|
"http": 409,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "状态冲突",
|
||||||
|
"hint": "刷新当前状态后再决定是否重试,不要盲目重放"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.internal",
|
||||||
|
"http": 500,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "unknown",
|
||||||
|
"summary": "内部错误",
|
||||||
|
"hint": "保留 trace_id,按错误日志定位,不要对调用方暴露内部细节"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.invalid_argument",
|
||||||
|
"http": 400,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "参数不合法",
|
||||||
|
"hint": "检查请求字段与类型,修正后重新提交"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.not_found",
|
||||||
|
"http": 404,
|
||||||
|
"severity": "info",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "资源不存在",
|
||||||
|
"hint": "确认标识是否正确,或资源是否已删除"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.rate_limited",
|
||||||
|
"http": 429,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "触发限流",
|
||||||
|
"hint": "按退避重试,不要立即打满重试"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.timeout",
|
||||||
|
"http": 504,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "possible",
|
||||||
|
"summary": "操作超时",
|
||||||
|
"hint": "先确认是否已产生副作用,再做有界重试"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.unauthorized",
|
||||||
|
"http": 401,
|
||||||
|
"severity": "warn",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "未授权",
|
||||||
|
"hint": "检查凭证是否缺失、过期或与资源不匹配"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.unavailable",
|
||||||
|
"http": 503,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "transient",
|
||||||
|
"side_effects": "none",
|
||||||
|
"summary": "依赖不可用",
|
||||||
|
"hint": "确认依赖健康后退避重试"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "sys.unknown_code",
|
||||||
|
"http": 500,
|
||||||
|
"severity": "error",
|
||||||
|
"retry": "never",
|
||||||
|
"side_effects": "unknown",
|
||||||
|
"summary": "未登记的错误码",
|
||||||
|
"hint": "按失败处理(fail-closed),并把该码补登记后再放行"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Package errcode 定义跨系统共用的错误码契约。
|
||||||
|
//
|
||||||
|
// 码格式为「域.助记名」(domain.mnemonic)。本包不依赖 HTTP、日志或任何产品类型:
|
||||||
|
// 调用方用 Registry 登记定义,用 E 携带码在进程内传递,用 JSON 跨进程传输。
|
||||||
|
// 未知码必须经 Registry.Resolve 回落到 sys.unknown_code,不得静默当成成功。
|
||||||
|
package errcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 域 2–32 字符,助记名 2–64 字符。域允许连字符,助记名允许下划线。
|
||||||
|
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9_]{1,63}$`)
|
||||||
|
|
||||||
|
// Code 是已规范化的错误码。空字符串不是合法码。
|
||||||
|
type Code string
|
||||||
|
|
||||||
|
// Valid 报告码是否符合 domain.mnemonic 格式。
|
||||||
|
func (c Code) Valid() bool { return codePattern.MatchString(string(c)) }
|
||||||
|
|
||||||
|
// Domain 返回点号前的域;格式不合法时返回空字符串。
|
||||||
|
func (c Code) Domain() string {
|
||||||
|
d, _, ok := strings.Cut(string(c), ".")
|
||||||
|
if !ok || !c.Valid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mnemonic 返回点号后的助记名;格式不合法时返回空字符串。
|
||||||
|
func (c Code) Mnemonic() string {
|
||||||
|
_, m, ok := strings.Cut(string(c), ".")
|
||||||
|
if !ok || !c.Valid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Code) String() string { return string(c) }
|
||||||
|
|
||||||
|
// ParseCode 校验并返回码。
|
||||||
|
func ParseCode(s string) (Code, error) {
|
||||||
|
c := Code(s)
|
||||||
|
if !c.Valid() {
|
||||||
|
return "", fmt.Errorf("errcode: invalid code %q", s)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
// 框架域。任何系统都可以直接使用,不表达产品语义。
|
||||||
|
const (
|
||||||
|
SysInternal Code = "sys.internal"
|
||||||
|
SysUnknownCode Code = "sys.unknown_code"
|
||||||
|
SysInvalidArgument Code = "sys.invalid_argument"
|
||||||
|
SysUnauthorized Code = "sys.unauthorized"
|
||||||
|
SysNotFound Code = "sys.not_found"
|
||||||
|
SysConflict Code = "sys.conflict"
|
||||||
|
SysRateLimited Code = "sys.rate_limited"
|
||||||
|
SysTimeout Code = "sys.timeout"
|
||||||
|
SysCancelled Code = "sys.cancelled"
|
||||||
|
SysUnavailable Code = "sys.unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 智能体执行域。对齐 agent-native contract.ErrorCode,并承接 pitransport 历史码。
|
||||||
|
const (
|
||||||
|
AgentCanceled Code = "agent.canceled"
|
||||||
|
AgentDeadlineExceeded Code = "agent.deadline_exceeded"
|
||||||
|
AgentBudgetExceeded Code = "agent.budget_exceeded"
|
||||||
|
AgentProviderUnavailable Code = "agent.provider_unavailable"
|
||||||
|
AgentProviderRejected Code = "agent.provider_rejected"
|
||||||
|
AgentToolRejected Code = "agent.tool_rejected"
|
||||||
|
AgentPathOutsideWorkspace Code = "agent.path_outside_workspace"
|
||||||
|
AgentMissingCapability Code = "agent.missing_capability"
|
||||||
|
AgentMissingDeliverable Code = "agent.missing_deliverable"
|
||||||
|
AgentEmptyOutput Code = "agent.empty_output"
|
||||||
|
AgentUnknownOutcome Code = "agent.unknown_outcome"
|
||||||
|
AgentInternal Code = "agent.internal"
|
||||||
|
AgentPromptEmpty Code = "agent.prompt_empty"
|
||||||
|
AgentUnauthorized Code = "agent.unauthorized"
|
||||||
|
AgentTimeout Code = "agent.timeout"
|
||||||
|
AgentTransient Code = "agent.transient"
|
||||||
|
AgentRunFailed Code = "agent.run_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 小队编排域。对齐 agent-squad 现有失败场景,本轮不改动该仓库。
|
||||||
|
const (
|
||||||
|
SquadRoleNotFound Code = "squad.role_not_found"
|
||||||
|
SquadBlueprintNotFound Code = "squad.blueprint_not_found"
|
||||||
|
SquadRunNotFound Code = "squad.run_not_found"
|
||||||
|
SquadGoalRequired Code = "squad.goal_required"
|
||||||
|
SquadHitlPending Code = "squad.hitl_pending"
|
||||||
|
SquadPlanRejected Code = "squad.plan_rejected"
|
||||||
|
SquadUnknownBackend Code = "squad.unknown_backend"
|
||||||
|
SquadStepFailed Code = "squad.step_failed"
|
||||||
|
SquadQualityFailed Code = "squad.quality_failed"
|
||||||
|
SquadCancelled Code = "squad.cancelled"
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Severity 是错误对调用方的严重级别,供日志与告警使用。
|
||||||
|
type Severity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityInfo Severity = "info"
|
||||||
|
SeverityWarn Severity = "warn"
|
||||||
|
SeverityError Severity = "error"
|
||||||
|
SeverityFatal Severity = "fatal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid 报告严重级别是否为已登记取值。
|
||||||
|
func (s Severity) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case SeverityInfo, SeverityWarn, SeverityError, SeverityFatal:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry 是该错误码的默认重试策略。实例级是否重试仍可由调用方覆盖。
|
||||||
|
type Retry string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RetryNever 禁止当成功路径重放。
|
||||||
|
RetryNever Retry = "never"
|
||||||
|
// RetryTransient 允许有界重试(传输抖动、限流、短暂不可用)。
|
||||||
|
RetryTransient Retry = "transient"
|
||||||
|
// RetryAlways 允许重试;仍须结合 SideEffects,possible 时不得无条件重放。
|
||||||
|
RetryAlways Retry = "always"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid 报告重试策略是否为已登记取值。
|
||||||
|
func (r Retry) Valid() bool {
|
||||||
|
switch r {
|
||||||
|
case RetryNever, RetryTransient, RetryAlways:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SideEffects 描述失败时是否可能已产生外部副作用。
|
||||||
|
// 语义对齐 agent-native contract:possible/unknown 禁止无条件重放。
|
||||||
|
type SideEffects string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SideEffectNone SideEffects = "none"
|
||||||
|
SideEffectPossible SideEffects = "possible"
|
||||||
|
SideEffectUnknown SideEffects = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid 报告副作用分类是否为已登记取值。
|
||||||
|
func (s SideEffects) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case SideEffectNone, SideEffectPossible, SideEffectUnknown:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition 是一条错误码的稳定元数据。Go 登记是权威来源,JSON 是导出形式。
|
||||||
|
type Definition struct {
|
||||||
|
Code Code `json:"code"`
|
||||||
|
HTTP int `json:"http"`
|
||||||
|
Severity Severity `json:"severity"`
|
||||||
|
Retry Retry `json:"retry"`
|
||||||
|
SideEffects SideEffects `json:"side_effects"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 检查定义是否可登记。
|
||||||
|
func (d Definition) Validate() error {
|
||||||
|
if !d.Code.Valid() {
|
||||||
|
return fmt.Errorf("errcode: invalid code %q", d.Code)
|
||||||
|
}
|
||||||
|
if d.HTTP < 100 || d.HTTP > 599 {
|
||||||
|
return fmt.Errorf("errcode: %s: http status %d out of range", d.Code, d.HTTP)
|
||||||
|
}
|
||||||
|
if !d.Severity.Valid() {
|
||||||
|
return fmt.Errorf("errcode: %s: invalid severity %q", d.Code, d.Severity)
|
||||||
|
}
|
||||||
|
if !d.Retry.Valid() {
|
||||||
|
return fmt.Errorf("errcode: %s: invalid retry %q", d.Code, d.Retry)
|
||||||
|
}
|
||||||
|
if !d.SideEffects.Valid() {
|
||||||
|
return fmt.Errorf("errcode: %s: invalid side_effects %q", d.Code, d.SideEffects)
|
||||||
|
}
|
||||||
|
if d.Summary == "" {
|
||||||
|
return fmt.Errorf("errcode: %s: summary is required", d.Code)
|
||||||
|
}
|
||||||
|
if d.Hint == "" {
|
||||||
|
return fmt.Errorf("errcode: %s: hint is required", d.Code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleDef(code Code) Definition {
|
||||||
|
return Definition{
|
||||||
|
Code: code,
|
||||||
|
HTTP: 503,
|
||||||
|
Severity: SeverityError,
|
||||||
|
Retry: RetryTransient,
|
||||||
|
SideEffects: SideEffectNone,
|
||||||
|
Summary: "提供方不可用",
|
||||||
|
Hint: "退避后重试",
|
||||||
|
Aliases: []string{"provider_unavailable"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeFormat(t *testing.T) {
|
||||||
|
ok := []Code{SysInternal, AgentProviderUnavailable, SquadRoleNotFound, "custom-domain.snake_name"}
|
||||||
|
for _, c := range ok {
|
||||||
|
if !c.Valid() {
|
||||||
|
t.Fatalf("%s should be valid", c)
|
||||||
|
}
|
||||||
|
if c.Domain() == "" || c.Mnemonic() == "" {
|
||||||
|
t.Fatalf("%s domain/mnemonic empty", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []Code{"", "NoDot", "A.b", "sys.", ".internal", "sys.Internal", "1sys.internal", "sys.a"}
|
||||||
|
for _, c := range bad {
|
||||||
|
if c.Valid() {
|
||||||
|
t.Fatalf("%s should be invalid", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterLookupAliasResolve(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
def := sampleDef(AgentProviderUnavailable)
|
||||||
|
if err := r.Register(def); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Register(def); err == nil {
|
||||||
|
t.Fatal("duplicate register should fail")
|
||||||
|
}
|
||||||
|
got, ok := r.Lookup("provider_unavailable")
|
||||||
|
if !ok || got.Code != AgentProviderUnavailable {
|
||||||
|
t.Fatalf("alias lookup: %+v ok=%v", got, ok)
|
||||||
|
}
|
||||||
|
if err := r.Alias("TRANSIENT", AgentProviderUnavailable); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := r.Lookup("TRANSIENT"); !ok {
|
||||||
|
t.Fatal("added alias missing")
|
||||||
|
}
|
||||||
|
unknown := r.Resolve("not.a.real")
|
||||||
|
if unknown.Code != SysUnknownCode {
|
||||||
|
t.Fatalf("resolve fallback = %s", unknown.Code)
|
||||||
|
}
|
||||||
|
if r.Known("not.a.real") {
|
||||||
|
t.Fatal("unknown should not be known")
|
||||||
|
}
|
||||||
|
if !r.Known(string(AgentProviderUnavailable)) {
|
||||||
|
t.Fatal("canonical should be known")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsBadAliasAndRollsBack(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
def := sampleDef(AgentTimeout)
|
||||||
|
def.Aliases = []string{"ok_alias", "bad alias"}
|
||||||
|
if err := r.Register(def); err == nil {
|
||||||
|
t.Fatal("expected invalid alias")
|
||||||
|
}
|
||||||
|
if r.Known(string(AgentTimeout)) || r.Known("ok_alias") {
|
||||||
|
t.Fatal("failed register must not leave partial state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadJSONRollback(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
raw := []byte(`[
|
||||||
|
{"code":"demo.one","http":400,"severity":"warn","retry":"never","side_effects":"none","summary":"一","hint":"检查"},
|
||||||
|
{"code":"demo.one","http":400,"severity":"warn","retry":"never","side_effects":"none","summary":"重复","hint":"检查"}
|
||||||
|
]`)
|
||||||
|
if err := r.LoadJSON(raw); err == nil {
|
||||||
|
t.Fatal("expected duplicate failure")
|
||||||
|
}
|
||||||
|
if len(r.List("")) != 0 {
|
||||||
|
t.Fatal("batch should roll back")
|
||||||
|
}
|
||||||
|
wrapped := []byte(`{"codes":[{"code":"demo.two","http":404,"severity":"info","retry":"never","side_effects":"none","summary":"没有","hint":"确认标识"}]}`)
|
||||||
|
if err := r.LoadJSON(wrapped); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !r.Known("demo.two") {
|
||||||
|
t.Fatal("wrapped catalog not loaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchAndExport(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if err := r.Register(sampleDef(AgentProviderUnavailable)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := r.Search("不可用")
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("search %d", len(found))
|
||||||
|
}
|
||||||
|
data, err := r.ExportJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var defs []Definition
|
||||||
|
if err := json.Unmarshal(data, &defs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(defs) != 1 || defs[0].Code != AgentProviderUnavailable {
|
||||||
|
t.Fatalf("export %+v", defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorWrapIsAsJSON(t *testing.T) {
|
||||||
|
root := fmt.Errorf("connection reset")
|
||||||
|
err := Wrap(root, AgentProviderUnavailable, "模型不可达", "model", "glm-4")
|
||||||
|
if !errors.Is(err, New(AgentProviderUnavailable, "")) {
|
||||||
|
t.Fatal("errors.Is should match code")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, root) {
|
||||||
|
t.Fatal("errors.Is should see cause")
|
||||||
|
}
|
||||||
|
var got *E
|
||||||
|
if !errors.As(err, &got) {
|
||||||
|
t.Fatal("errors.As")
|
||||||
|
}
|
||||||
|
if From(fmt.Errorf("wrap: %w", err)).Code != AgentProviderUnavailable {
|
||||||
|
t.Fatal("From should walk the chain")
|
||||||
|
}
|
||||||
|
if err.ErrorCode() != string(AgentProviderUnavailable) {
|
||||||
|
t.Fatal(err.ErrorCode())
|
||||||
|
}
|
||||||
|
fields := err.ErrorFields()
|
||||||
|
if fields["model"] != "glm-4" || fields["reason"] != "模型不可达" {
|
||||||
|
t.Fatalf("fields %+v", fields)
|
||||||
|
}
|
||||||
|
fields["model"] = "mutated"
|
||||||
|
if err.ErrorFields()["model"] != "glm-4" {
|
||||||
|
t.Fatal("ErrorFields must return a copy")
|
||||||
|
}
|
||||||
|
if Wrap(nil, SysInternal, "x") != nil {
|
||||||
|
t.Fatal("Wrap(nil) must be nil")
|
||||||
|
}
|
||||||
|
data, errJSON := json.Marshal(err)
|
||||||
|
if errJSON != nil {
|
||||||
|
t.Fatal(errJSON)
|
||||||
|
}
|
||||||
|
var back E
|
||||||
|
if err := json.Unmarshal(data, &back); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Code != AgentProviderUnavailable || back.Unwrap() == nil || back.Unwrap().Error() != root.Error() {
|
||||||
|
t.Fatalf("roundtrip %+v cause=%v", back, back.Unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPanicOnOddFields(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("expected panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
New(SysInternal, "x", "only-key")
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// E 是携带稳定错误码的错误。可用 errors.Is / errors.As 识别,也可 JSON 跨进程传输。
|
||||||
|
// 跨进程反序列化后 Cause 只保留文本,不再持有原始 Unwrap 链。
|
||||||
|
type E struct {
|
||||||
|
Code Code `json:"code"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Fields map[string]any `json:"fields,omitempty"`
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 构造一个不包装原因的错误。kv 必须成对出现,键必须是非空字符串;
|
||||||
|
// 这是调用方编程错误,会 panic(与 log/slog 的字段约定相同)。
|
||||||
|
func New(code Code, reason string, kv ...any) *E {
|
||||||
|
return &E{Code: code, Reason: reason, Fields: fieldsFrom(kv)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap 用错误码包装 err。err 为 nil 时返回 nil,以便直接 return。
|
||||||
|
func Wrap(err error, code Code, reason string, kv ...any) *E {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &E{Code: code, Reason: reason, Fields: fieldsFrom(kv), cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 实现 error。格式为 code: reason: cause,空段省略。
|
||||||
|
func (e *E) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
msg := string(e.Code)
|
||||||
|
if e.Reason != "" {
|
||||||
|
msg += ": " + e.Reason
|
||||||
|
}
|
||||||
|
if e.cause != nil {
|
||||||
|
msg += ": " + e.cause.Error()
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap 返回被包装的原因。
|
||||||
|
func (e *E) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is 在目标也是 *E 且错误码相同时匹配,供 errors.Is 使用。
|
||||||
|
func (e *E) Is(target error) bool {
|
||||||
|
t, ok := target.(*E)
|
||||||
|
if !ok || t == nil || e == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return e.Code != "" && e.Code == t.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCode 返回规范码字符串。日志系统用鸭子接口读取,无需 import 本包。
|
||||||
|
func (e *E) ErrorCode() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorFields 返回字段副本(含 reason)。日志系统用鸭子接口读取。
|
||||||
|
func (e *E) ErrorFields() map[string]any {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(e.Fields)+1)
|
||||||
|
for k, v := range e.Fields {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
if e.Reason != "" {
|
||||||
|
out["reason"] = e.Reason
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// From 沿 Unwrap 链提取最外层 *E。不存在时返回 nil。
|
||||||
|
func From(err error) *E {
|
||||||
|
var e *E
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON 序列化码、原因、字段与原因文本。
|
||||||
|
func (e *E) MarshalJSON() ([]byte, error) {
|
||||||
|
if e == nil {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
wire := struct {
|
||||||
|
Code Code `json:"code"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Fields map[string]any `json:"fields,omitempty"`
|
||||||
|
Cause string `json:"cause,omitempty"`
|
||||||
|
}{
|
||||||
|
Code: e.Code,
|
||||||
|
Reason: e.Reason,
|
||||||
|
Fields: e.Fields,
|
||||||
|
}
|
||||||
|
if e.cause != nil {
|
||||||
|
wire.Cause = e.cause.Error()
|
||||||
|
}
|
||||||
|
return json.Marshal(wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON 恢复错误。原因文本包装为普通 error,Unwrap 不再指向原对象。
|
||||||
|
func (e *E) UnmarshalJSON(data []byte) error {
|
||||||
|
var wire struct {
|
||||||
|
Code Code `json:"code"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Fields map[string]any `json:"fields,omitempty"`
|
||||||
|
Cause string `json:"cause,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &wire); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Code = wire.Code
|
||||||
|
e.Reason = wire.Reason
|
||||||
|
e.Fields = wire.Fields
|
||||||
|
e.cause = nil
|
||||||
|
if wire.Cause != "" {
|
||||||
|
e.cause = errors.New(wire.Cause)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldsFrom(kv []any) map[string]any {
|
||||||
|
if len(kv) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(kv)%2 != 0 {
|
||||||
|
panic("errcode: odd number of key-value arguments")
|
||||||
|
}
|
||||||
|
fields := make(map[string]any, len(kv)/2)
|
||||||
|
for i := 0; i < len(kv); i += 2 {
|
||||||
|
key, ok := kv[i].(string)
|
||||||
|
if !ok || key == "" {
|
||||||
|
panic(fmt.Sprintf("errcode: field key must be a non-empty string, got %T", kv[i]))
|
||||||
|
}
|
||||||
|
fields[key] = kv[i+1]
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 别名用于承接历史码(snake_case 或 UPPER_SNAKE),不是第二套规范码。
|
||||||
|
var aliasPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{1,63}$`)
|
||||||
|
|
||||||
|
// Registry 是线程安全的错误码登记表。
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
byCode map[Code]Definition
|
||||||
|
aliases map[string]Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry 返回空登记表。种子码由 domains 包登记,测试使用独立实例。
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
byCode: map[Code]Definition{},
|
||||||
|
aliases: map[string]Code{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default 是进程内默认登记表。导入 domains 包后会填入种子码。
|
||||||
|
var Default = NewRegistry()
|
||||||
|
|
||||||
|
// Register 登记一条定义。码重复、别名冲突或字段不合法时返回错误,不部分写入。
|
||||||
|
func (r *Registry) Register(def Definition) error {
|
||||||
|
if err := def.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.byCode[def.Code]; ok {
|
||||||
|
return fmt.Errorf("errcode: duplicate code %s", def.Code)
|
||||||
|
}
|
||||||
|
if owner, ok := r.aliases[string(def.Code)]; ok {
|
||||||
|
return fmt.Errorf("errcode: code %s collides with alias of %s", def.Code, owner)
|
||||||
|
}
|
||||||
|
staged := make([]string, 0, len(def.Aliases))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
if alias == string(def.Code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !aliasPattern.MatchString(alias) {
|
||||||
|
return fmt.Errorf("errcode: %s: invalid alias %q", def.Code, alias)
|
||||||
|
}
|
||||||
|
if _, dup := seen[alias]; dup {
|
||||||
|
return fmt.Errorf("errcode: %s: duplicate alias %q", def.Code, alias)
|
||||||
|
}
|
||||||
|
if _, ok := r.byCode[Code(alias)]; ok {
|
||||||
|
return fmt.Errorf("errcode: alias %q collides with registered code", alias)
|
||||||
|
}
|
||||||
|
if owner, ok := r.aliases[alias]; ok {
|
||||||
|
return fmt.Errorf("errcode: alias %q already points at %s", alias, owner)
|
||||||
|
}
|
||||||
|
seen[alias] = struct{}{}
|
||||||
|
staged = append(staged, alias)
|
||||||
|
}
|
||||||
|
slices.Sort(staged)
|
||||||
|
def.Aliases = staged
|
||||||
|
r.byCode[def.Code] = def
|
||||||
|
for _, alias := range staged {
|
||||||
|
r.aliases[alias] = def.Code
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustRegister 登记失败时 panic,仅用于启动期种子数据。
|
||||||
|
func (r *Registry) MustRegister(def Definition) {
|
||||||
|
if err := r.Register(def); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias 为已登记的码追加一个历史别名。
|
||||||
|
func (r *Registry) Alias(alias string, code Code) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
def, ok := r.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("errcode: alias target %s is not registered", code)
|
||||||
|
}
|
||||||
|
if alias == string(code) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !aliasPattern.MatchString(alias) {
|
||||||
|
return fmt.Errorf("errcode: invalid alias %q", alias)
|
||||||
|
}
|
||||||
|
if _, ok := r.byCode[Code(alias)]; ok {
|
||||||
|
return fmt.Errorf("errcode: alias %q collides with registered code", alias)
|
||||||
|
}
|
||||||
|
if owner, ok := r.aliases[alias]; ok && owner != code {
|
||||||
|
return fmt.Errorf("errcode: alias %q already points at %s", alias, owner)
|
||||||
|
}
|
||||||
|
r.aliases[alias] = code
|
||||||
|
if !slices.Contains(def.Aliases, alias) {
|
||||||
|
def.Aliases = append(def.Aliases, alias)
|
||||||
|
slices.Sort(def.Aliases)
|
||||||
|
r.byCode[code] = def
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup 按规范码或别名查找。未登记返回 false,不回落。
|
||||||
|
func (r *Registry) Lookup(s string) (Definition, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if def, ok := r.byCode[Code(s)]; ok {
|
||||||
|
return cloneDef(def), true
|
||||||
|
}
|
||||||
|
if code, ok := r.aliases[s]; ok {
|
||||||
|
def, ok := r.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
return cloneDef(def), true
|
||||||
|
}
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve 查找规范码或别名。未知、空串一律回落 sys.unknown_code(fail-closed)。
|
||||||
|
// 若回落码本身尚未登记,返回合成定义,避免空表把调用方打崩。
|
||||||
|
func (r *Registry) Resolve(s string) Definition {
|
||||||
|
if def, ok := r.Lookup(s); ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if s != string(SysUnknownCode) {
|
||||||
|
if def, ok := r.Lookup(string(SysUnknownCode)); ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Definition{
|
||||||
|
Code: SysUnknownCode,
|
||||||
|
HTTP: 500,
|
||||||
|
Severity: SeverityError,
|
||||||
|
Retry: RetryNever,
|
||||||
|
SideEffects: SideEffectUnknown,
|
||||||
|
Summary: "未登记的错误码",
|
||||||
|
Hint: "按失败处理,并把该码补登记到域注册表",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known 报告字符串是否为已登记规范码或别名。
|
||||||
|
func (r *Registry) Known(s string) bool {
|
||||||
|
_, ok := r.Lookup(s)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 返回已登记定义。domain 非空时只返回该域。顺序按码字典序。
|
||||||
|
func (r *Registry) List(domain string) []Definition {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]Definition, 0, len(r.byCode))
|
||||||
|
for _, def := range r.byCode {
|
||||||
|
if domain != "" && def.Code.Domain() != domain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cloneDef(def))
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Definition) int {
|
||||||
|
return strings.Compare(string(a.Code), string(b.Code))
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 在码、摘要、处置建议与别名中做大小写不敏感子串匹配。
|
||||||
|
func (r *Registry) Search(q string) []Definition {
|
||||||
|
q = strings.ToLower(strings.TrimSpace(q))
|
||||||
|
if q == "" {
|
||||||
|
return r.List("")
|
||||||
|
}
|
||||||
|
out := []Definition{}
|
||||||
|
for _, def := range r.List("") {
|
||||||
|
if definitionMatches(def, q) {
|
||||||
|
out = append(out, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func definitionMatches(def Definition, q string) bool {
|
||||||
|
if strings.Contains(strings.ToLower(string(def.Code)), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(def.Summary), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(def.Hint), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
if strings.Contains(strings.ToLower(alias), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportJSON 导出全部定义(UTF-8、缩进),供跨语言消费与目录对照。
|
||||||
|
func (r *Registry) ExportJSON() ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
enc := json.NewEncoder(buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(r.List("")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadJSON 从 JSON 数组或 {"codes":[...]} 登记定义。任一失败则回滚本批。
|
||||||
|
func (r *Registry) LoadJSON(data []byte) error {
|
||||||
|
defs, err := decodeDefinitions(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
added := make([]Code, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
if err := r.Register(def); err != nil {
|
||||||
|
r.unregister(added)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
added = append(added, def.Code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDefinitions(data []byte) ([]Definition, error) {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("errcode: empty catalog json")
|
||||||
|
}
|
||||||
|
if data[0] == '[' {
|
||||||
|
var defs []Definition
|
||||||
|
if err := json.Unmarshal(data, &defs); err != nil {
|
||||||
|
return nil, fmt.Errorf("errcode: catalog json: %w", err)
|
||||||
|
}
|
||||||
|
return defs, nil
|
||||||
|
}
|
||||||
|
var wrap struct {
|
||||||
|
Codes []Definition `json:"codes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &wrap); err != nil {
|
||||||
|
return nil, fmt.Errorf("errcode: catalog json: %w", err)
|
||||||
|
}
|
||||||
|
return wrap.Codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) unregister(codes []Code) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
drop := map[Code]struct{}{}
|
||||||
|
for _, code := range codes {
|
||||||
|
drop[code] = struct{}{}
|
||||||
|
delete(r.byCode, code)
|
||||||
|
}
|
||||||
|
for alias, code := range r.aliases {
|
||||||
|
if _, ok := drop[code]; ok {
|
||||||
|
delete(r.aliases, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDef(def Definition) Definition {
|
||||||
|
def.Aliases = slices.Clone(def.Aliases)
|
||||||
|
return def
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
// Package httpx 把错误码写成统一 HTTP 信封,并透传追踪标识。
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraceHeader 是跨系统传递追踪标识的请求/响应头。
|
||||||
|
const TraceHeader = "X-Trace-Id"
|
||||||
|
|
||||||
|
type traceKey struct{}
|
||||||
|
|
||||||
|
// Envelope 是对外错误体。成功响应不使用此结构。
|
||||||
|
type Envelope struct {
|
||||||
|
Error Body `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body 是错误对象。code 恒为规范码;未知输入已回落 sys.unknown_code。
|
||||||
|
type Body struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
TraceID string `json:"trace_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace 为每个请求保证一个合法的追踪标识:沿用合法的请求头,否则生成。
|
||||||
|
// 标识同时写回响应头与请求上下文。
|
||||||
|
func Trace(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := sanitizeTrace(r.Header.Get(TraceHeader))
|
||||||
|
if id == "" {
|
||||||
|
id = newTraceID()
|
||||||
|
}
|
||||||
|
w.Header().Set(TraceHeader, id)
|
||||||
|
ctx := context.WithValue(r.Context(), traceKey{}, id)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceID 读取中间件写入的追踪标识,其次读取合法请求头。
|
||||||
|
func TraceID(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if id, ok := r.Context().Value(traceKey{}).(string); ok && id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return sanitizeTrace(r.Header.Get(TraceHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError 按注册表把 err 写成 JSON 信封并设置 HTTP 状态码。
|
||||||
|
// reg 为空时使用 errcode.Default。非 *errcode.E 视为 sys.internal,原文放入 reason。
|
||||||
|
func WriteError(reg *errcode.Registry, w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if reg == nil {
|
||||||
|
reg = errcode.Default
|
||||||
|
}
|
||||||
|
code := errcode.SysInternal
|
||||||
|
reason := ""
|
||||||
|
if err != nil {
|
||||||
|
reason = err.Error()
|
||||||
|
}
|
||||||
|
if e := errcode.From(err); e != nil {
|
||||||
|
code = e.Code
|
||||||
|
reason = e.Reason
|
||||||
|
if reason == "" && e.Unwrap() != nil {
|
||||||
|
reason = e.Unwrap().Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
def := reg.Resolve(string(code))
|
||||||
|
traceID := TraceID(r)
|
||||||
|
if traceID != "" {
|
||||||
|
w.Header().Set(TraceHeader, traceID)
|
||||||
|
}
|
||||||
|
writeEnvelope(w, def.HTTP, Envelope{Error: Body{
|
||||||
|
Code: string(def.Code),
|
||||||
|
Message: def.Summary,
|
||||||
|
Reason: reason,
|
||||||
|
TraceID: traceID,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeEnvelope(w http.ResponseWriter, status int, env Envelope) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTraceID() string {
|
||||||
|
var buf [16]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return "trace-unavailable"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeTrace(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) < 8 || len(s) > 128 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"error-code-system/domains"
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTraceGeneratesAndEchoes(t *testing.T) {
|
||||||
|
var seen string
|
||||||
|
h := Trace(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = TraceID(r)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Header().Get(TraceHeader) == "" || seen != rec.Header().Get(TraceHeader) {
|
||||||
|
t.Fatalf("generated trace header=%q seen=%q", rec.Header().Get(TraceHeader), seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set(TraceHeader, "trace-from-caller")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Header().Get(TraceHeader) != "trace-from-caller" {
|
||||||
|
t.Fatalf("echo = %s", rec.Header().Get(TraceHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set(TraceHeader, "bad id")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Header().Get(TraceHeader) == "bad id" || rec.Header().Get(TraceHeader) == "" {
|
||||||
|
t.Fatal("invalid trace should be replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteErrorEnvelope(t *testing.T) {
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set(TraceHeader, "trace-abc-1234")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
err := errcode.Wrap(errors.New("dial timeout"), errcode.AgentProviderUnavailable, "模型不可达", "model", "glm-4")
|
||||||
|
WriteError(reg, rec, req, err)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
var env Envelope
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.Error.Code != string(errcode.AgentProviderUnavailable) || env.Error.Message == "" {
|
||||||
|
t.Fatalf("body %+v", env.Error)
|
||||||
|
}
|
||||||
|
if env.Error.Reason != "模型不可达" || env.Error.TraceID != "trace-abc-1234" {
|
||||||
|
t.Fatalf("body %+v", env.Error)
|
||||||
|
}
|
||||||
|
if rec.Header().Get(TraceHeader) != "trace-abc-1234" {
|
||||||
|
t.Fatal("trace header not echoed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteErrorUnknownFallsBack(t *testing.T) {
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteError(reg, rec, httptest.NewRequest(http.MethodGet, "/", nil), errcode.New("no.such_code", "x"))
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var env Envelope
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.Error.Code != string(errcode.SysUnknownCode) {
|
||||||
|
t.Fatalf("code %s", env.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"error-code-system/cli"
|
||||||
|
_ "error-code-system/domains"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(cli.Main(os.Args[1:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Package server 提供错误码查询与校验 HTTP 服务。默认监听 :8091。
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"error-code-system/errcode"
|
||||||
|
"error-code-system/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultAddr 是 errcode-serve 的默认监听地址。
|
||||||
|
const DefaultAddr = ":8091"
|
||||||
|
|
||||||
|
// Server 对外提供已登记错误码的只读 API。
|
||||||
|
type Server struct {
|
||||||
|
Reg *errcode.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 使用给定注册表。nil 表示 errcode.Default(需已导入 domains)。
|
||||||
|
func New(reg *errcode.Registry) *Server { return &Server{Reg: reg} }
|
||||||
|
|
||||||
|
// Handler 返回带追踪中间件的路由。
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||||
|
mux.HandleFunc("GET /v1/codes", s.handleList)
|
||||||
|
mux.HandleFunc("GET /v1/codes/{code}", s.handleGet)
|
||||||
|
mux.HandleFunc("POST /v1/validate", s.handleValidate)
|
||||||
|
mux.HandleFunc("GET /v1/export", s.handleExport)
|
||||||
|
return httpx.Trace(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe 在 addr 上服务。addr 为空时用 DefaultAddr。
|
||||||
|
func (s *Server) ListenAndServe(addr string) error {
|
||||||
|
if strings.TrimSpace(addr) == "" {
|
||||||
|
addr = DefaultAddr
|
||||||
|
}
|
||||||
|
return http.ListenAndServe(addr, s.Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reg() *errcode.Registry {
|
||||||
|
if s != nil && s.Reg != nil {
|
||||||
|
return s.Reg
|
||||||
|
}
|
||||||
|
return errcode.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defs := s.reg().Search(r.URL.Query().Get("q"))
|
||||||
|
if domain := r.URL.Query().Get("domain"); domain != "" {
|
||||||
|
filtered := make([]errcode.Definition, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Code.Domain() == domain {
|
||||||
|
filtered = append(filtered, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defs = filtered
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, defs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
def, ok := s.reg().Lookup(code)
|
||||||
|
if !ok {
|
||||||
|
httpx.WriteError(s.reg(), w, r, errcode.New(errcode.SysNotFound, code))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
type validateRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Codes []string `json:"codes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validateResult struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
Known bool `json:"known"`
|
||||||
|
Resolved string `json:"resolved"`
|
||||||
|
Definition errcode.Definition `json:"definition"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req validateRequest
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
httpx.WriteError(s.reg(), w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求体不是合法 JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputs := req.Codes
|
||||||
|
if req.Code != "" {
|
||||||
|
inputs = append([]string{req.Code}, inputs...)
|
||||||
|
}
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
httpx.WriteError(s.reg(), w, r, errcode.New(errcode.SysInvalidArgument, "code 或 codes 必填"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]validateResult, 0, len(inputs))
|
||||||
|
for _, input := range inputs {
|
||||||
|
def, ok := s.reg().Lookup(input)
|
||||||
|
resolved := def
|
||||||
|
if !ok {
|
||||||
|
resolved = s.reg().Resolve(input)
|
||||||
|
}
|
||||||
|
results = append(results, validateResult{
|
||||||
|
Input: input,
|
||||||
|
Known: ok,
|
||||||
|
Resolved: string(resolved.Code),
|
||||||
|
Definition: resolved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := s.reg().ExportJSON()
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(s.reg(), w, r, errcode.Wrap(err, errcode.SysInternal, "导出失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if domain := r.URL.Query().Get("domain"); domain != "" {
|
||||||
|
raw, err := json.Marshal(s.reg().List(domain))
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(s.reg(), w, r, errcode.Wrap(err, errcode.SysInternal, "导出失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = append(raw, '\n')
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
_ = enc.Encode(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"error-code-system/domains"
|
||||||
|
"error-code-system/errcode"
|
||||||
|
"error-code-system/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
reg := errcode.NewRegistry()
|
||||||
|
domains.RegisterAll(reg)
|
||||||
|
return httptest.NewServer(New(reg).Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthListGetValidate(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
res, err := http.Get(srv.URL + "/v1/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK || res.Header.Get(httpx.TraceHeader) == "" {
|
||||||
|
t.Fatalf("health %d trace %q", res.StatusCode, res.Header.Get(httpx.TraceHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = http.Get(srv.URL + "/v1/codes?domain=agent&q=provider")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var defs []errcode.Definition
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&defs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(defs) == 0 {
|
||||||
|
t.Fatal("expected agent provider codes")
|
||||||
|
}
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Code.Domain() != "agent" || !strings.Contains(string(def.Code), "provider") && !strings.Contains(def.Summary, "提供") {
|
||||||
|
t.Fatalf("unexpected hit %+v", def.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = http.Get(srv.URL + "/v1/codes/provider_unavailable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var def errcode.Definition
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&def); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if def.Code != errcode.AgentProviderUnavailable {
|
||||||
|
t.Fatalf("alias get resolved to %s", def.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = http.Get(srv.URL + "/v1/codes/missing.code")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusNotFound {
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
t.Fatalf("missing status %d body %s", res.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/validate", strings.NewReader(`{"codes":["TIMEOUT","nope"]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set(httpx.TraceHeader, "trace-server-1")
|
||||||
|
res, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var payload struct {
|
||||||
|
Results []struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
Known bool `json:"known"`
|
||||||
|
Resolved string `json:"resolved"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(payload.Results) != 2 || !payload.Results[0].Known || payload.Results[0].Resolved != string(errcode.AgentTimeout) {
|
||||||
|
t.Fatalf("validate %+v", payload.Results)
|
||||||
|
}
|
||||||
|
if payload.Results[1].Known || payload.Results[1].Resolved != string(errcode.SysUnknownCode) {
|
||||||
|
t.Fatalf("unknown %+v", payload.Results[1])
|
||||||
|
}
|
||||||
|
if res.Header.Get(httpx.TraceHeader) != "trace-server-1" {
|
||||||
|
t.Fatal("trace not echoed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user