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,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