diff --git a/internal/errors/error.go b/internal/errors/error.go new file mode 100644 index 0000000..adebe51 --- /dev/null +++ b/internal/errors/error.go @@ -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) +) diff --git a/internal/errors/error_test.go b/internal/errors/error_test.go new file mode 100644 index 0000000..53962de --- /dev/null +++ b/internal/errors/error_test.go @@ -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) + } +} diff --git a/internal/errors/helper.go b/internal/errors/helper.go new file mode 100644 index 0000000..d1a455a --- /dev/null +++ b/internal/errors/helper.go @@ -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 +} diff --git a/internal/errors/http.go b/internal/errors/http.go new file mode 100644 index 0000000..bc762c3 --- /dev/null +++ b/internal/errors/http.go @@ -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, + }, + } +}