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

181 lines
4.5 KiB
Go

package httpx
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"strings"
)
const (
CodeAUTHZ = "AUTHZ"
CodeVALIDATION = "VALIDATION"
CodeVERSION_CONFLICT = "VERSION_CONFLICT"
CodeIDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
CodeINTERNAL = "INTERNAL"
HeaderRequestID = "X-Request-ID"
HeaderErrorCode = "X-Error-Code"
)
type Envelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Error string `json:"error"`
Details map[string]any `json:"details,omitempty"`
}
func CodeFromStatus(status int) string {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return CodeAUTHZ
case http.StatusConflict:
return CodeVERSION_CONFLICT
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return CodeVALIDATION
default:
if status >= 500 {
return CodeINTERNAL
}
if status == http.StatusNotFound {
return CodeAUTHZ
}
return CodeVALIDATION
}
}
func SafeMessage(code string) string {
switch code {
case CodeAUTHZ:
return "无权访问"
case CodeVALIDATION:
return "请求参数无效"
case CodeVERSION_CONFLICT:
return "资源版本冲突"
case CodeIDEMPOTENCY_CONFLICT:
return "重复提交"
default:
return "请求无法完成"
}
}
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 RequestID(r *http.Request) string {
if r == nil {
return newRequestID()
}
if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
return v
}
return newRequestID()
}
func newRequestID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return "req_" + hex.EncodeToString(b[:])
}
func PublicLegacy(code, legacy string) string {
legacy = strings.TrimSpace(legacy)
if legacy == "" || LooksInternal(legacy) {
return code
}
if strings.HasPrefix(legacy, "<") || strings.Contains(legacy, "<script") {
return code
}
return legacy
}
func Write(w http.ResponseWriter, status int, code, legacy, requestID string, details map[string]any) {
write(w, nil, status, code, legacy, requestID, "", details)
}
// WriteHTTP 写入错误信封,并记录 method/path。safeMessage 为空时使用按码的固定文案。
func WriteHTTP(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) {
if requestID == "" {
requestID = requestIDFrom(w, r)
}
write(w, r, status, code, legacy, requestID, safeMessage, details)
}
func requestIDFrom(w http.ResponseWriter, r *http.Request) string {
if r != nil {
if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
return v
}
}
if w != nil {
if v := strings.TrimSpace(w.Header().Get(HeaderRequestID)); v != "" {
return v
}
}
return RequestID(r)
}
func write(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) {
if code == "" {
code = CodeFromStatus(status)
}
if requestID == "" {
requestID = requestIDFrom(w, r)
}
if safeMessage == "" {
safeMessage = SafeMessage(code)
}
env := Envelope{
Code: code,
Message: safeMessage,
RequestID: requestID,
Error: PublicLegacy(code, legacy),
Details: details,
}
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
w.Header().Set(HeaderRequestID, requestID)
w.Header().Set(HeaderErrorCode, code)
logError(r, status, code, requestID)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(env)
}
func logError(r *http.Request, status int, code, requestID string) {
level := slog.LevelWarn
if status >= 500 {
level = slog.LevelError
}
args := []any{"code", code, "status", status, "request_id", requestID}
if r != nil {
args = append(args, "method", r.Method, "path", stripQuery(r.URL.Path))
}
slog.Log(context.Background(), level, "http_error", args...)
}
func stripQuery(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/"
}
if i := strings.IndexAny(path, "?#"); i >= 0 {
path = path[:i]
}
return path
}