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:
+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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user