Add a shared error-code catalog so failures can be traced by stable codes.
ci / vet-and-test (push) Successful in 3s
ci / docker-checkout-hostgateway (push) Successful in 3s

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:
weichen
2026-09-29 13:43:12 +00:00
co-authored by Cursor
commit bd53e9ba79
33 changed files with 2489 additions and 0 deletions
+20
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
*.test
/bin/
+101
View File
@@ -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
View File
@@ -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
}
+50
View File
@@ -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())
}
}
+78
View File
@@ -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)
}
}
}
+44
View File
@@ -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。
+32
View File
@@ -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` 能看到执行根因。
+34
View File
@@ -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。不得当成成功。
+36
View File
@@ -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":[...]}`。本批任一记录不合法时,整批回滚。
+59
View File
@@ -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"))
}
+209
View File
@@ -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"
]
}
]
+26
View File
@@ -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)
}
}
}
+25
View File
@@ -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)
}
}
}
+13
View File
@@ -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")
}
+35
View File
@@ -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
}
+33
View File
@@ -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,
}
}
+26
View File
@@ -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,
"运行已取消", "取消后的步骤保持可恢复,不要写成成功"))
}
+92
View File
@@ -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"
}
]
+26
View File
@@ -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,
"依赖不可用", "确认依赖健康后退避重试"))
}
+92
View File
@@ -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),并把该码补登记后再放行"
}
]
+50
View File
@@ -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
}
+50
View File
@@ -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"
)
+103
View File
@@ -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
}
+177
View File
@@ -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")
}
+154
View File
@@ -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
}
+276
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module error-code-system
go 1.27
+116
View File
@@ -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
}
+86
View File
@@ -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)
}
}
+12
View File
@@ -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:]))
}
+147
View File
@@ -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)
}
+106
View File
@@ -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")
}
}