feat(errors): 统一 AppError(Code/Message/Cause/HTTPCode + Wrap %w + HTTPStatus + ToJSON {error:{code,message}})
This commit is contained in:
1 parent
ebeecc7a32
commit
7f7cb8fbf7
4 files changed
+420
No files matched your search
@@ -0,0 +1,104 @@
|
||||
// Package errors provides the unified application error type used throughout
|
||||
// Cyrene v2.
|
||||
//
|
||||
// Design (DevDocs/02-development-constraints.md §四):
|
||||
//
|
||||
// - An AppError carries a stable machine-readable Code, a user-safe Message,
|
||||
// an optional Cause, and an HTTP status code.
|
||||
// - Errors must be wrapped with %w semantics so errors.Is/errors.As can
|
||||
// traverse the chain, and the original Cause is preserved for callers that
|
||||
// need the raw error (logging, diagnostics, errors.Is).
|
||||
// - Internal errors are never exposed directly to users: Message is the only
|
||||
// user-facing text, while Cause keeps the underlying error out of the
|
||||
// client response (see ToJSON).
|
||||
// - Each module defines its own ErrXxx sentinels; the common ones live here.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AppError is the unified error type for Cyrene v2.
|
||||
//
|
||||
// It satisfies the error interface (Error returns Message) and the unwrap
|
||||
// contract (Unwrap returns Cause), so errors.Is/errors.As descend through the
|
||||
// chain. The zero value is a usable AppError with an empty Code and Message.
|
||||
type AppError struct {
|
||||
// Code is a stable, machine-readable error code (e.g. "not_found").
|
||||
Code string
|
||||
|
||||
// Message is the user-safe, readable text. Internal detail never goes here;
|
||||
// keep the raw cause in Cause instead.
|
||||
Message string
|
||||
|
||||
// Cause is the underlying error that produced this AppError. It is preserved
|
||||
// for errors.Is/errors.As traversal and for logging. May be nil.
|
||||
Cause error
|
||||
|
||||
// HTTPCode is the HTTP status to return with this error. 0 means "unset",
|
||||
// which HTTPStatus resolves to http.StatusInternalServerError (500).
|
||||
HTTPCode int
|
||||
}
|
||||
|
||||
// Error returns the user-facing Message. It implements the error interface.
|
||||
func (e *AppError) Error() string { return e.Message }
|
||||
|
||||
// Unwrap returns the Cause so errors.Is/errors.As can traverse the chain.
|
||||
// It returns nil when there is no underlying cause.
|
||||
func (e *AppError) Unwrap() error { return e.Cause }
|
||||
|
||||
// New creates an AppError with the given Code and Message and no cause.
|
||||
// HTTPCode is left 0 (HTTPStatus maps it to 500 unless overridden).
|
||||
func New(code, msg string) *AppError {
|
||||
return &AppError{Code: code, Message: msg}
|
||||
}
|
||||
|
||||
// Wrap creates an AppError that wraps err as its Cause, with a formatted
|
||||
// Message and the given Code. The Message is produced with fmt.Sprintf(format,
|
||||
// args...) and is intended to be user-safe; the original error is preserved in
|
||||
// Cause so errors.Is(err, original) and errors.As still work.
|
||||
//
|
||||
// The wrapped error inherits its HTTP status from err (see HTTPStatus), so
|
||||
// wrapping a sentinel such as ErrNotFound keeps status 404.
|
||||
func Wrap(err error, code, format string, args ...any) *AppError {
|
||||
return &AppError{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
Cause: err,
|
||||
HTTPCode: HTTPStatus(err),
|
||||
}
|
||||
}
|
||||
|
||||
// newWithHTTP builds a sentinel AppError that carries an explicit HTTP status.
|
||||
func newWithHTTP(code, msg string, httpCode int) *AppError {
|
||||
return &AppError{Code: code, Message: msg, HTTPCode: httpCode}
|
||||
}
|
||||
|
||||
// Common application error sentinels, mirroring the HTTP status codes they map
|
||||
// to. Modules should define their own ErrXxx and may wrap these as Causes.
|
||||
var (
|
||||
// ErrInternal is the generic internal error (500).
|
||||
ErrInternal = newWithHTTP("internal", "internal error", http.StatusInternalServerError)
|
||||
|
||||
// ErrNotFound means a requested resource does not exist (404).
|
||||
ErrNotFound = newWithHTTP("not_found", "not found", http.StatusNotFound)
|
||||
|
||||
// ErrConflict means the request conflicts with the current state (409).
|
||||
ErrConflict = newWithHTTP("conflict", "conflict", http.StatusConflict)
|
||||
|
||||
// ErrUnauthorized means the caller is not authenticated (401).
|
||||
ErrUnauthorized = newWithHTTP("unauthorized", "unauthorized", http.StatusUnauthorized)
|
||||
|
||||
// ErrForbidden means the caller is authenticated but not allowed (403).
|
||||
ErrForbidden = newWithHTTP("forbidden", "forbidden", http.StatusForbidden)
|
||||
|
||||
// ErrBadRequest means the request is malformed (400).
|
||||
ErrBadRequest = newWithHTTP("bad_request", "bad request", http.StatusBadRequest)
|
||||
|
||||
// ErrRateLimited means the caller exceeded a rate limit (429).
|
||||
ErrRateLimited = newWithHTTP("rate_limited", "rate limited", http.StatusTooManyRequests)
|
||||
|
||||
// ErrTimeout means the operation exceeded its deadline (503).
|
||||
ErrTimeout = newWithHTTP("timeout", "timeout", http.StatusServiceUnavailable)
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := New("not_found", "用户不存在")
|
||||
if e.Code != "not_found" {
|
||||
t.Fatalf("Code = %q, want %q", e.Code, "not_found")
|
||||
}
|
||||
if e.Message != "用户不存在" {
|
||||
t.Fatalf("Message = %q, want %q", e.Message, "用户不存在")
|
||||
}
|
||||
if e.Error() != "用户不存在" {
|
||||
t.Fatalf("Error() = %q, want Message", e.Error())
|
||||
}
|
||||
if e.Cause != nil {
|
||||
t.Fatalf("Cause = %v, want nil", e.Cause)
|
||||
}
|
||||
if e.HTTPCode != 0 {
|
||||
t.Fatalf("HTTPCode = %d, want 0 (unset)", e.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_UnwrapNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := New("x", "y").Unwrap(); got != nil {
|
||||
t.Fatalf("Unwrap() = %v, want nil for New", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := ErrNotFound
|
||||
wrapped := Wrap(original, "not_found", "找不到用户 %s", "alice")
|
||||
|
||||
if wrapped.Cause != original {
|
||||
t.Fatalf("Cause = %v, want original ErrNotFound", wrapped.Cause)
|
||||
}
|
||||
if wrapped.Message != "找不到用户 alice" {
|
||||
t.Fatalf("Message = %q, want %q", wrapped.Message, "找不到用户 alice")
|
||||
}
|
||||
if wrapped.Unwrap() != original {
|
||||
t.Fatalf("Unwrap() != original cause")
|
||||
}
|
||||
if !errors.Is(wrapped, original) {
|
||||
t.Fatalf("errors.Is(err, original) = false, want true (%%w penetration)")
|
||||
}
|
||||
if !errors.Is(wrapped, ErrNotFound) {
|
||||
t.Fatalf("errors.Is(err, ErrNotFound) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_DoesNotOverMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// errors.Is must not match an unrelated sentinel solely because the wrapper
|
||||
// uses that code string.
|
||||
wrapped := Wrap(ErrNotFound, "conflict", "写入冲突")
|
||||
if errors.Is(wrapped, ErrConflict) {
|
||||
t.Fatalf("errors.Is(err, ErrConflict) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_ChainPenetrates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outer := Wrap(Wrap(ErrNotFound, "internal", "加载配置失败"), "conflict", "写入冲突")
|
||||
if !errors.Is(outer, ErrNotFound) {
|
||||
t.Fatalf("errors.Is(outer, ErrNotFound) = false, want true through nested wraps")
|
||||
}
|
||||
|
||||
var appErr *AppError
|
||||
if !errors.As(outer, &appErr) {
|
||||
t.Fatalf("errors.As(outer) = false, want true")
|
||||
}
|
||||
if appErr.Code != "conflict" {
|
||||
t.Fatalf("extracted Code = %q, want outer %q", appErr.Code, "conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAppError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"sentinel", ErrNotFound, true},
|
||||
{"wrapped", Wrap(ErrNotFound, "not_found", "missing"), true},
|
||||
{"nested_wrap", Wrap(Wrap(ErrNotFound, "internal", "x"), "bad_request", "y"), true},
|
||||
{"std_error", fmt.Errorf("boom"), false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := IsAppError(c.err); got != c.want {
|
||||
t.Fatalf("IsAppError(%v) = %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outer := Wrap(Wrap(ErrNotFound, "internal", "加载失败"), "conflict", "写入冲突")
|
||||
appErr, ok := As(outer)
|
||||
if !ok {
|
||||
t.Fatalf("As(outer) ok = false, want true")
|
||||
}
|
||||
if appErr.Code != "conflict" {
|
||||
t.Fatalf("As code = %q, want outer %q", appErr.Code, "conflict")
|
||||
}
|
||||
|
||||
_, ok = As(fmt.Errorf("boom"))
|
||||
if ok {
|
||||
t.Fatalf("As(std error) ok = true, want false")
|
||||
}
|
||||
|
||||
// a bare wrapped AppError chain: As must extract the typed error
|
||||
inner := Wrap(ErrUnauthorized, "unauthorized", "请先登录")
|
||||
extracted, ok := As(inner)
|
||||
if !ok {
|
||||
t.Fatalf("As(inner) ok = false, want true")
|
||||
}
|
||||
if extracted != inner {
|
||||
t.Fatalf("As(inner) != inner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"internal", ErrInternal, http.StatusInternalServerError},
|
||||
{"not_found", ErrNotFound, http.StatusNotFound},
|
||||
{"conflict", ErrConflict, http.StatusConflict},
|
||||
{"unauthorized", ErrUnauthorized, http.StatusUnauthorized},
|
||||
{"forbidden", ErrForbidden, http.StatusForbidden},
|
||||
{"bad_request", ErrBadRequest, http.StatusBadRequest},
|
||||
{"rate_limited", ErrRateLimited, http.StatusTooManyRequests},
|
||||
{"timeout", ErrTimeout, http.StatusServiceUnavailable},
|
||||
{"std_error", fmt.Errorf("boom"), http.StatusInternalServerError},
|
||||
{"nil", nil, http.StatusInternalServerError},
|
||||
{"custom_no_http", New("custom", "x"), http.StatusInternalServerError},
|
||||
{"wrap_inherits_cause", Wrap(ErrNotFound, "not_found", "missing"), http.StatusNotFound},
|
||||
{"wrap_std_error", Wrap(fmt.Errorf("db down"), "internal", "查询失败"), http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := HTTPStatus(c.err); got != c.want {
|
||||
t.Fatalf("HTTPStatus(%v) = %d, want %d", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := Wrap(ErrNotFound, "not_found", "资源不存在")
|
||||
js := ToJSON(e)
|
||||
|
||||
if len(js) != 1 {
|
||||
t.Fatalf("ToJSON keys = %d, want exactly 1 (error)", len(js))
|
||||
}
|
||||
obj, ok := js["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("error field is not a map, got %T", js["error"])
|
||||
}
|
||||
if got := obj["code"]; got != "not_found" {
|
||||
t.Fatalf("code = %v, want %q", got, "not_found")
|
||||
}
|
||||
if got := obj["message"]; got != "资源不存在" {
|
||||
t.Fatalf("message = %v, want %q", got, "资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToJSON_AlignsClientContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
js := ToJSON(ErrUnauthorized)
|
||||
obj := js["error"].(map[string]any)
|
||||
if obj["code"] != "unauthorized" {
|
||||
t.Fatalf("code = %v, want %q", obj["code"], "unauthorized")
|
||||
}
|
||||
if obj["message"] != "unauthorized" {
|
||||
t.Fatalf("message = %v, want %q", obj["message"], "unauthorized")
|
||||
}
|
||||
if _, exists := js["data"]; exists {
|
||||
t.Fatalf("ToJSON leaked a data key; contract is only {error:{code,message}}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToJSON_DoesNotExposeInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := fmt.Errorf("connection refused: host=db-internal password=secret")
|
||||
js := ToJSON(raw)
|
||||
|
||||
obj := js["error"].(map[string]any)
|
||||
if obj["code"] != "internal" {
|
||||
t.Fatalf("code = %v, want %q", obj["code"], "internal")
|
||||
}
|
||||
if obj["message"] != "internal error" {
|
||||
t.Fatalf("message = %v, want %q", obj["message"], "internal error")
|
||||
}
|
||||
body := fmt.Sprintf("%v", js)
|
||||
if strings.Contains(body, "secret") || strings.Contains(body, "db-internal") {
|
||||
t.Fatalf("ToJSON leaked internal detail: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_SafeMessageKeepsCause(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := fmt.Errorf("insert failed: PRIMARY KEY violation")
|
||||
wrapped := Wrap(raw, "conflict", "资源已存在")
|
||||
|
||||
obj := ToJSON(wrapped)["error"].(map[string]any)
|
||||
if obj["message"] != "资源已存在" {
|
||||
t.Fatalf("message = %v, want %q", obj["message"], "资源已存在")
|
||||
}
|
||||
if strings.Contains(obj["message"].(string), "PRIMARY KEY") {
|
||||
t.Fatalf("user-facing message leaked internal detail: %s", obj["message"])
|
||||
}
|
||||
// The raw cause is still preserved for logging/diagnostics.
|
||||
if wrapped.Cause != raw {
|
||||
t.Fatalf("Cause not preserved, got %v", wrapped.Cause)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package errors
|
||||
|
||||
import stdErrors "errors"
|
||||
|
||||
// IsAppError reports whether err is an *AppError or wraps one anywhere in its
|
||||
// chain. It uses errors.As under the hood, so wrapped causes are detected too.
|
||||
func IsAppError(err error) bool {
|
||||
var target *AppError
|
||||
return stdErrors.As(err, &target)
|
||||
}
|
||||
|
||||
// As extracts the first *AppError in err's chain, reporting whether one was
|
||||
// found. It mirrors errors.As but returns the typed error directly for
|
||||
// convenience. The returned error is the outermost AppError in the chain.
|
||||
func As(err error) (*AppError, bool) {
|
||||
var target *AppError
|
||||
ok := stdErrors.As(err, &target)
|
||||
return target, ok
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
stdErrors "errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPStatus resolves the HTTP status code for err.
|
||||
//
|
||||
// If err (or anything wrapped by it) is an AppError with a non-zero HTTPCode,
|
||||
// that code is returned; otherwise the fallback is http.StatusInternalServerError
|
||||
// (500). New and Wrap propagate the cause's status, so wrapping a sentinel such
|
||||
// as ErrNotFound keeps status 404.
|
||||
func HTTPStatus(err error) int {
|
||||
var appErr *AppError
|
||||
if stdErrors.As(err, &appErr) && appErr.HTTPCode != 0 {
|
||||
return appErr.HTTPCode
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// ToJSON renders err as an RFC 7807 style error object, aligned with the client
|
||||
// API contract {error:{code,message}}.
|
||||
//
|
||||
// For an AppError (or one wrapped by it) the Code and Message are returned
|
||||
// verbatim. For any other error a generic internal error is returned so that
|
||||
// internal details are never leaked to the user; the underlying error is kept
|
||||
// only in the Cause field for logging.
|
||||
func ToJSON(err error) map[string]any {
|
||||
var appErr *AppError
|
||||
if stdErrors.As(err, &appErr) {
|
||||
return map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": appErr.Code,
|
||||
"message": appErr.Message,
|
||||
},
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": ErrInternal.Code,
|
||||
"message": ErrInternal.Message,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user