feat(errs): AppError type with HTTP status mapping

This commit is contained in:
2026-04-28 14:49:43 +08:00
parent 222335e604
commit b95bd13377
2 changed files with 156 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
// Package errs defines the application's error type (AppError) and helpers
// to map domain error codes to HTTP status codes. Service layers wrap
// underlying errors with errs.Wrap / errs.New; transport layers use errs.As
// + errs.HTTPStatus to translate them into Google API style responses
// ({code, message, fields, request_id}).
package errs
import (
"errors"
"fmt"
"net/http"
)
// Code is a stable, machine-readable identifier for a class of error.
// Codes are part of the public API contract surfaced by HTTP responses.
type Code string
// Canonical error codes. Keep this list in sync with HTTPStatus and any
// downstream consumers (e.g., HTTP transport, OpenAPI schemas).
const (
CodeInvalidInput Code = "invalid_input"
CodeUnauthorized Code = "unauthorized"
CodeForbidden Code = "forbidden"
CodeNotFound Code = "not_found"
CodeConflict Code = "conflict"
CodeRateLimited Code = "rate_limited"
CodeUpstream Code = "upstream_error"
CodeUnavailable Code = "unavailable"
CodeInternal Code = "internal"
)
// AppError is the application's structured error type. It carries a stable
// Code, a human-readable Message, an optional underlying Cause, and an
// optional Fields map for per-field validation details.
type AppError struct {
Code Code
Message string
Cause error
Fields map[string]any
}
// Error implements the error interface. When a Cause is present the format
// is "<message>: <cause>"; otherwise just the message is returned.
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s", e.Message, e.Cause.Error())
}
return e.Message
}
// Unwrap exposes the underlying cause so errors.Is / errors.As can traverse
// the chain.
func (e *AppError) Unwrap() error { return e.Cause }
// WithFields attaches per-field validation details and returns the receiver
// for chaining.
func (e *AppError) WithFields(f map[string]any) *AppError {
e.Fields = f
return e
}
// New constructs a new AppError with the given code and message.
func New(code Code, msg string) *AppError {
return &AppError{Code: code, Message: msg}
}
// Wrap wraps an existing error with an AppError carrying the given code and
// message. The original error is preserved as Cause and remains discoverable
// via errors.Is / errors.As.
func Wrap(cause error, code Code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Cause: cause}
}
// As is a convenience wrapper around errors.As that extracts the first
// *AppError in err's chain. It returns (nil, false) when err is nil or no
// AppError is found.
func As(err error) (*AppError, bool) {
if err == nil {
return nil, false
}
var ae *AppError
if errors.As(err, &ae) {
return ae, true
}
return nil, false
}
// HTTPStatus maps an error Code to its canonical HTTP status code. Unknown
// codes default to 500 Internal Server Error.
func HTTPStatus(code Code) int {
switch code {
case CodeInvalidInput:
return http.StatusBadRequest
case CodeUnauthorized:
return http.StatusUnauthorized
case CodeForbidden:
return http.StatusForbidden
case CodeNotFound:
return http.StatusNotFound
case CodeConflict:
return http.StatusConflict
case CodeRateLimited:
return http.StatusTooManyRequests
case CodeUpstream:
return http.StatusBadGateway
case CodeUnavailable:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}