Files
warmbly/internal/errx/errx.go
T

156 lines
5.3 KiB
Go

package errx
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
// Error represents a business error with a code and message.
type Error struct {
Code Code `json:"code"`
Message string `json:"message"`
// Identifier optionally overrides the machine-readable `code` in the JSON
// response. Without it every error of the same HTTP class is indistinguishable
// to a client, so a caller that needs to branch on a specific condition has
// nothing stable to match on. Empty means "derive it from Code".
Identifier string `json:"-"`
// Public marks an Internal-class message that was written for the caller
// and is safe to show. Internal messages are otherwise replaced with a
// generic sentence, because they are usually built from the underlying
// error. The exceptions are the few that tell an operator something they
// can act on, like a mail transport that cannot deliver.
Public bool `json:"-"`
}
// Error implements error interface.
func (e *Error) Error() string {
return fmt.Sprintf("%s (%d): %s", codeToString[e.Code], e.Code, e.Message)
}
// New creates a new business error.
func New(code Code, message string) *Error {
return &Error{Code: code, Message: message}
}
// NewPublic creates an error whose message is shown to the caller even when it
// is server-class. Use it only for a message a person can act on, never for one
// derived from an underlying error.
func NewPublic(code Code, message string) *Error {
return &Error{Code: code, Message: message, Public: true}
}
// NewWithIdentifier creates a business error carrying its own machine-readable
// identifier, for conditions a client is expected to detect and handle
// specifically rather than just display.
func NewWithIdentifier(code Code, identifier, message string) *Error {
return &Error{Code: code, Message: message, Identifier: identifier}
}
// identifier returns the response `code`: the error's own when set, otherwise
// the generic one for its HTTP class.
func (e *Error) identifier() string {
if e.Identifier != "" {
return e.Identifier
}
if id, ok := codeToIdentifier[e.Code]; ok {
return id
}
return codeToIdentifier[Internal]
}
// resolve is the HTTP status and title to answer with. A Code outside the
// table maps to 0, which gin leaves at 200, so an error would be reported as a
// success; anything unknown is an internal error instead.
func (e *Error) resolve() (int, string) {
if status, ok := codeToHTTP[e.Code]; ok {
return status, codeToString[e.Code]
}
return codeToHTTP[Internal], codeToString[Internal]
}
// ResponseCode is the machine-readable `code` this error answers with, for
// callers that embed errors in a body of their own (per-row results).
func (e *Error) ResponseCode() string { return e.identifier() }
// --- Predefined errors (exported) ---
var (
ErrUnauthorized = New(Unauthorized, "Token not found.")
ErrForbidden = New(Forbidden, "You don't have access to this feature.")
ErrNotFound = New(NotFound, "Resource not found.")
ErrConflict = New(Conflict, "resource already exists")
ErrUnprocessable = New(Unprocessable, "validation failed")
ErrServiceDown = New(ServiceUnavailable, "service unavailable")
)
// --- Gin handler helper ---
type response struct {
Error string `json:"error"`
Message string `json:"message"`
Code string `json:"code"`
RequestID string `json:"request_id,omitempty"`
}
func InternalError() *Error {
return New(Internal, "Something went wrong.")
}
func Handle(c *gin.Context, err error) {
var bizErr *Error
if errors.As(err, &bizErr) {
JSON(c, bizErr)
return
}
// Unexpected error → treat as internal
Handle(c, InternalError())
}
// JSON sends a business error as JSON response.
func JSON(c *gin.Context, err *Error) {
httpCode, httpError := err.resolve()
c.JSON(httpCode, response{
Error: httpError,
Message: clientMessage(c, err),
Code: err.identifier(),
RequestID: c.GetString("request_id"),
})
}
// genericServerMessage is the only thing a 5xx says to a caller.
const genericServerMessage = "Something went wrong."
// clientMessage is what the caller is told.
//
// An Internal message describes something that went wrong inside the service,
// and handlers routinely built one from the underlying error: driver text with
// SQLSTATE codes and column names, provider responses, file paths. None of that
// helps the caller and all of it helps somebody mapping the system, which is
// what CASA 6.2.1 is about.
//
// So an Internal error answers with one fixed sentence and the request id. The
// real message is logged against that id, which is where an operator should be
// reading it from anyway.
//
// Only Internal. The other server-class codes carry messages a developer wrote
// for the caller and that the caller can act on ("no mailbox workers are
// available right now", "this provider is not configured on this instance"),
// and blanking those would replace working guidance with a shrug.
func clientMessage(c *gin.Context, err *Error) string {
status, _ := err.resolve()
if err.Code != Internal || err.Public {
return err.Message
}
if detail := err.Message; detail != "" && detail != genericServerMessage {
log.Error().
Str("request_id", c.GetString("request_id")).
Str("path", c.FullPath()).
Int("status", status).
Str("detail", detail).
Msg("server error returned to client")
}
return genericServerMessage
}