// 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) // ErrPayloadTooLarge means the request body exceeds the allowed size (413). ErrPayloadTooLarge = newWithHTTP("payload_too_large", "payload too large", http.StatusRequestEntityTooLarge) // ErrUnsupportedMediaType means the request carries a media type the endpoint // does not accept (415). ErrUnsupportedMediaType = newWithHTTP("unsupported_media_type", "unsupported media type", http.StatusUnsupportedMediaType) // 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) )