Files
weichen f3f23a3018 feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
2026-09-28 02:27:16 +00:00

415 lines
9.2 KiB
Go

// Package errcode 是全系统错误码注册表。
// 领域包保留哨兵错误;server 把它们绑定到这里的稳定码。
// 日志与 error_events 审计只记录码、request_id 和脱敏后的摘要。
package errcode
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
ClassTransient = "transient"
ClassResumable = "resumable"
ClassFatal = "fatal"
ClassCancelled = "cancelled"
)
// Def 是一条可检索的错误码。Message 与 Guide 必须是可公开文案。
type Def struct {
ID string
Status int
Message string
Class string
Module string
Guide string
Retryable bool
Actions []string
}
// Event 是一条错误审计记录。Upstream 在 Report 时脱敏。
type Event struct {
ID int64 `json:"id,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Code string `json:"code"`
Class string `json:"class,omitempty"`
HTTPStatus int `json:"httpStatus,omitempty"`
RequestID string `json:"requestId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
WorkspaceID string `json:"workspaceId,omitempty"`
Module string `json:"module,omitempty"`
PhaseID string `json:"phaseId,omitempty"`
Path string `json:"path,omitempty"`
Method string `json:"method,omitempty"`
Source string `json:"source,omitempty"`
Upstream string `json:"upstream,omitempty"`
ActorUserID int64 `json:"actorUserId,omitempty"`
Retryable bool `json:"retryable,omitempty"`
UserMessage string `json:"userMessage,omitempty"`
}
// Auditor 持久化错误事件。写入失败不得影响主请求。
type Auditor interface {
Record(ctx context.Context, ev Event) error
}
var (
mu sync.RWMutex
byID = map[string]Def{}
legacyIndex = map[string]string{}
prefixes []prefixRule
sentinels []sentinel
auditor Auditor
)
type prefixRule struct {
prefix string
id string
}
type sentinel struct {
err error
id string
}
func register(d Def) Def {
if d.ID == "" {
panic("errcode: empty id")
}
if d.Class == "" {
d.Class = ClassFatal
}
if d.Message == "" {
d.Message = "请求无法完成"
}
if d.Guide == "" {
mod := d.Module
if mod == "" {
mod = "server"
}
d.Guide = fmt.Sprintf("用 request_id 在服务日志或管理台「错误审计」中检索错误码 %s。负责模块:%s。", d.ID, mod)
}
if d.Class == ClassTransient || d.Class == ClassResumable {
d.Retryable = true
}
mu.Lock()
defer mu.Unlock()
if _, ok := byID[d.ID]; ok {
panic("errcode: duplicate id " + d.ID)
}
byID[d.ID] = d
return d
}
func registerLegacy(message, id string) {
message = strings.TrimSpace(message)
if message == "" || id == "" {
panic("errcode: empty legacy mapping")
}
mu.Lock()
defer mu.Unlock()
if _, ok := byID[id]; !ok {
panic("errcode: legacy maps to unknown id " + id)
}
if prev, ok := legacyIndex[message]; ok && prev != id {
panic("errcode: legacy message remapped " + message)
}
legacyIndex[message] = id
}
func registerPrefix(prefix, id string) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
mu.Lock()
defer mu.Unlock()
if _, ok := byID[id]; !ok {
panic("errcode: prefix maps to unknown id " + id)
}
prefixes = append(prefixes, prefixRule{prefix: prefix, id: id})
}
// Bind 把领域哨兵错误绑到已注册的码上。后绑定的不覆盖先绑定的 errors.Is 命中。
func Bind(err error, id string) {
if err == nil {
panic("errcode: bind nil")
}
mu.Lock()
defer mu.Unlock()
if _, ok := byID[id]; !ok {
panic("errcode: bind unknown id " + id)
}
sentinels = append(sentinels, sentinel{err: err, id: id})
}
// Lookup 按码查询。
func Lookup(id string) (Def, bool) {
mu.RLock()
defer mu.RUnlock()
d, ok := byID[id]
return d, ok
}
// All 返回全部错误码,按 ID 排序。
func All() []Def {
mu.RLock()
defer mu.RUnlock()
out := make([]Def, 0, len(byID))
for _, d := range byID {
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// FromStatus 把 HTTP 状态映射到粗粒度但稳定的码。
func FromStatus(status int) Def {
id := "REQUEST_INVALID"
switch status {
case 401:
id = "AUTH_UNAUTHENTICATED"
case 403:
id = "AUTH_FORBIDDEN"
case 404:
id = "RESOURCE_NOT_FOUND"
case 405:
id = "REQUEST_METHOD_NOT_ALLOWED"
case 409:
id = "RESOURCE_CONFLICT"
case 429:
id = "RATE_LIMITED"
case 503:
id = "SERVICE_UNAVAILABLE"
default:
if status >= 500 {
id = "INTERNAL"
}
}
d, ok := Lookup(id)
if !ok {
return Def{ID: id, Status: status, Message: "请求无法完成", Class: ClassFatal, Module: "server"}
}
return d
}
// FromLegacy 用公开文案(或其中的 JSON message/error)选择详细码。
// 未命中时退回 FromStatus,不改变调用方的 HTTP 状态。
func FromLegacy(status int, legacy string) Def {
key := normalizeLegacy(legacy)
mu.RLock()
id, ok := legacyIndex[key]
var pref string
if !ok {
low := strings.ToLower(key)
for _, p := range prefixes {
if strings.HasPrefix(low, p.prefix) {
pref = p.id
break
}
}
}
mu.RUnlock()
if ok {
if d, found := Lookup(id); found {
return d
}
}
if pref != "" {
if d, found := Lookup(pref); found {
return d
}
}
return FromStatus(status)
}
func normalizeLegacy(legacy string) string {
legacy = strings.TrimSpace(legacy)
if legacy == "" || !strings.HasPrefix(legacy, "{") {
return legacy
}
var m map[string]any
if err := json.Unmarshal([]byte(legacy), &m); err != nil {
return legacy
}
if msg, _ := m["message"].(string); strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg)
}
if msg, _ := m["error"].(string); strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg)
}
return legacy
}
// Error 是带稳定码的错误,可被 FromError / 流式分类直接读取。
type Error struct {
Def Def
Cause error
}
func (e *Error) Error() string {
if e == nil {
return ""
}
if e.Cause != nil {
return e.Cause.Error()
}
if e.Def.Message != "" {
return e.Def.Message
}
return e.Def.ID
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func (e *Error) ErrorCode() string {
if e == nil {
return ""
}
return e.Def.ID
}
// New 包装一个已注册错误码。
func New(id string, cause error) error {
d, ok := Lookup(id)
if !ok {
d = Def{ID: id, Message: "请求无法完成", Class: ClassFatal, Module: "server"}
}
return &Error{Def: d, Cause: cause}
}
type errorCoder interface {
ErrorCode() string
}
// FromError 读取 typed 错误码或已绑定的哨兵错误。
func FromError(err error) (Def, bool) {
if err == nil {
return Def{}, false
}
var coded *Error
if errors.As(err, &coded) && coded != nil && coded.Def.ID != "" {
if d, ok := Lookup(coded.Def.ID); ok {
return d, true
}
return coded.Def, true
}
var coder errorCoder
if errors.As(err, &coder) && coder != nil {
if d, ok := Lookup(coder.ErrorCode()); ok {
return d, true
}
}
mu.RLock()
defer mu.RUnlock()
for _, s := range sentinels {
if errors.Is(err, s.err) {
d, ok := byID[s.id]
return d, ok
}
}
return Def{}, false
}
// SetAuditor 安装进程级审计写入器。未安装时 Report 只跳过落库。
func SetAuditor(a Auditor) {
mu.Lock()
auditor = a
mu.Unlock()
}
// Report 记录一条错误审计。落库失败只打日志,不向调用方返回错误。
func Report(ctx context.Context, ev Event) {
_ = ctx
if ev.Code == "" {
ev.Code = "INTERNAL"
}
if ev.Class == "" {
if d, ok := Lookup(ev.Code); ok {
ev.Class = d.Class
ev.Retryable = d.Retryable
if ev.UserMessage == "" {
ev.UserMessage = d.Message
}
if ev.Module == "" {
ev.Module = d.Module
}
} else {
ev.Class = ClassFatal
}
}
ev.Upstream = Sanitize(ev.Upstream)
ev.UserMessage = Sanitize(ev.UserMessage)
ev.Path = stripQuery(ev.Path)
if ev.Source == "" {
ev.Source = "http"
}
mu.RLock()
a := auditor
mu.RUnlock()
if a == nil {
return
}
cctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if err := a.Record(cctx, ev); err != nil {
slog.Warn("error_event_audit_degraded", "code", ev.Code, "request_id", ev.RequestID, "err", err.Error())
}
}
// Sanitize 去掉疑似内部信息,并截断到 160 个字符。
func Sanitize(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if looksInternal(s) {
return ""
}
return truncate(s, 160)
}
func looksInternal(s string) bool {
low := strings.ToLower(s)
needles := []string{
"sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password",
"secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback",
"transaction", "constraint", "violates",
}
for _, n := range needles {
if strings.Contains(low, n) {
return true
}
}
if strings.Contains(s, "/home/") || strings.Contains(s, "/var/") || strings.Contains(s, "\\") {
return true
}
return false
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
r := []rune(s)
return string(r[:maxRunes])
}
func stripQuery(path string) string {
path = strings.TrimSpace(path)
if i := strings.IndexAny(path, "?#"); i >= 0 {
path = path[:i]
}
return path
}