Errors: Introduce error type with Grafana specific metadata (#47504)

This commit is contained in:
Emil Tullstedt 2022-06-14 10:50:11 +02:00 committed by GitHub
parent 2d3cc26aa8
commit 264c2a9d1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 632 additions and 2 deletions

View File

@ -3,12 +3,17 @@ package response
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
jsoniter "github.com/json-iterator/go"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
jsoniter "github.com/json-iterator/go"
"github.com/grafana/grafana/pkg/util/errutil"
)
// Response is an HTTP response interface.
@ -82,7 +87,13 @@ func (r *NormalResponse) WriteTo(ctx *models.ReqContext) {
r.body = bytes.NewBuffer(b)
}
}
ctx.Logger.Error(r.errMessage, "error", r.err, "remote_addr", ctx.RemoteAddr(), "traceID", traceID)
logger := ctx.Logger.Error
var gfErr *errutil.Error
if errors.As(r.err, &gfErr) {
logger = gfErr.LogLevel.LogFunc(ctx.Logger)
}
logger(r.errMessage, "error", r.err, "remote_addr", ctx.RemoteAddr(), "traceID", traceID)
}
header := ctx.Resp.Header()
@ -214,6 +225,20 @@ func Error(status int, message string, err error) *NormalResponse {
return resp
}
// Err creates an error response based on an errutil.Error error.
func Err(err error) *NormalResponse {
grafanaErr := &errutil.Error{}
if !errors.As(err, grafanaErr) {
return Error(http.StatusInternalServerError, "", fmt.Errorf("unexpected error type [%s]: %w", reflect.TypeOf(err), err))
}
resp := JSON(grafanaErr.Reason.Status().HTTPStatus(), grafanaErr.Public())
resp.errMessage = string(grafanaErr.Reason.Status())
resp.err = grafanaErr
return resp
}
// Empty creates an empty NormalResponse.
func Empty(status int) *NormalResponse {
return Respond(status, nil)

44
pkg/util/errutil/doc.go Normal file
View File

@ -0,0 +1,44 @@
// Package errutil provides utilities for working with errors in Grafana.
//
// Idiomatic errors in Grafana provides a combination of static and
// dynamic information that is useful to developers, system
// administrators and end users alike.
//
// Grafana itself can use the static information to infer the general
// category of error, retryability, log levels, and similar. A developer
// can combine static and dynamic information from logs to determine
// what went wrong and where even when access to the runtime environment
// is impossible. Server admins can use the information from the logs to
// monitor the health of their Grafana instance. End users will receive
// an appropriate amount of information to be able to correctly
// determine the best course of action when receiving an error.
//
// It is also important that implementing errors idiomatically comes
// naturally to experienced and beginner Go developers alike and is
// compatible with standard library features such as the ones in
// the errors package. To achieve this, Grafana's errors are divided
// into the Base and Error types, where the Base contains static
// information about a category of errors that may occur within a
// service and Error contains the combination of static and dynamic
// information for a particular instance of an error.
//
// A Base would typically be provided as a package-level variable for a
// service using the NewBase constructor with a CoreStatus and a unique
// static message ID that identifies the general structure of the public
// message attached to the specific error.
// var errNotFound = errutil.NewBase(errutil.StatusNotFound, "service.not-found")
// This Base can now be used to construct a regular Go error with the
// Base.Errorf method using the same structure as fmt.Errorf:
// return errNotFound.Errorf("looked for thing with ID %d, but it wasn't there: %w", id, err)
//
// By default, the end user will be sent the static message ID and a
// message which is the string representation of the CoreStatus. It is
// possible to override the message sent to the end user by using
// the WithPublicMessage functional option when creating a new Base
// var errNotFound = errutil.NewBase(errutil.StatusNotFound "service.not-found", WithPublicMessage("The thing is missing."))
// If a dynamic message is needed, the Template type extends Base with a
// Go template using text/template from the standard library, refer to
// the documentation related to the Template type for usage examples.
// It is also possible, but discouraged, to manually edit the fields of
// an Error.
package errutil

165
pkg/util/errutil/errors.go Normal file
View File

@ -0,0 +1,165 @@
package errutil
import (
"errors"
"fmt"
)
// Base represents the static information about a specific error.
// Always use NewBase to create new instances of Base.
type Base struct {
// Because Base is typically instantiated as a package or global
// variable, having private members reduces the probability of a
// bug messing with the error base.
reason StatusReason
messageID string
publicMessage string
logLevel LogLevel
}
// NewBase initializes a Base that is used to construct Error:s.
// The reason is used to determine the status code that should be
// returned for the error, and the msgID is passed to the caller
// to serve as the base for user facing error messages.
//
// msgID should be structured as component.error-brief, for example
// login.failed-authentication
// dashboards.validation-error
// dashboards.uid-already-exists
func NewBase(reason StatusReason, msgID string, opts ...BaseOpt) Base {
b := Base{
reason: reason,
messageID: msgID,
logLevel: reason.Status().LogLevel(),
}
for _, opt := range opts {
b = opt(b)
}
return b
}
type BaseOpt func(Base) Base
// WithLogLevel sets a custom log level for all errors instantiated from
// this Base.
//
// Used as a functional option to NewBase.
func WithLogLevel(lvl LogLevel) BaseOpt {
return func(b Base) Base {
b.logLevel = lvl
return b
}
}
// WithPublicMessage sets the default public message that will be used
// for errors based on this Base.
//
// Used as a functional option to NewBase.
func WithPublicMessage(message string) BaseOpt {
return func(b Base) Base {
b.publicMessage = message
return b
}
}
// Errorf creates a new Error with the Reason and MessageID from
// Base, and Message and Underlying will be populated using
// the rules of fmt.Errorf.
func (b Base) Errorf(format string, args ...interface{}) Error {
err := fmt.Errorf(format, args...)
return Error{
Reason: b.reason,
LogMessage: err.Error(),
PublicMessage: b.publicMessage,
MessageID: b.messageID,
Underlying: errors.Unwrap(err),
LogLevel: b.logLevel,
}
}
// Error is the error type for errors within Grafana, extending
// the Go error type with Grafana specific metadata to reduce
// boilerplate error handling for status codes and internationalization
// support.
//
// Error implements Unwrap and Is to natively support Go 1.13 style
// errors as described in https://go.dev/blog/go1.13-errors .
type Error struct {
Reason StatusReason
MessageID string
LogMessage string
Underlying error
PublicMessage string
PublicPayload map[string]interface{}
LogLevel LogLevel
}
// MarshalJSON returns an error, we do not want raw Error:s being
// marshaled into JSON.
//
// Use Public to convert the Error into a PublicError which can be
// marshaled. This is not done automatically, as that conversion is
// lossy.
func (e Error) MarshalJSON() ([]byte, error) {
return nil, fmt.Errorf("errutil.Error cannot be directly marshaled into JSON")
}
// Error implements the error interface.
func (e Error) Error() string {
return fmt.Sprintf("[%s] %s", e.MessageID, e.LogMessage)
}
// Unwrap is used by errors.As to iterate over the sequence of
// underlying errors until a matching type is found.
func (e Error) Unwrap() error {
return e.Underlying
}
// Is is used by errors.Is to allow for custom definitions of equality
// between two errors.
func (e Error) Is(other error) bool {
// The linter complains that it wants to use errors.As because it
// handles unwrapping, we don't want to do that here since we want
// to validate the equality between the two objects.
// errors.Is handles the unwrapping, should you want it.
//nolint:errorlint
o, ok := other.(Error)
if !ok {
return false
}
return o.Reason == e.Reason && o.MessageID == e.MessageID && o.Error() == e.Error()
}
// PublicError is derived from Error and only contains information
// available to the end user.
type PublicError struct {
StatusCode int `json:"statusCode"`
MessageID string `json:"messageId"`
Message string `json:"message,omitempty"`
Extra map[string]interface{} `json:"extra,omitempty"`
}
// Public returns a subset of the error with non-sensitive information
// that may be relayed to the caller.
func (e Error) Public() PublicError {
message := e.PublicMessage
if message == "" {
if e.Reason == StatusUnknown {
// The unknown status is equal to the empty string.
message = string(StatusInternal)
} else {
message = string(e.Reason.Status())
}
}
return PublicError{
StatusCode: e.Reason.Status().HTTPStatus(),
MessageID: e.MessageID,
Message: message,
Extra: e.PublicPayload,
}
}

View File

@ -0,0 +1,60 @@
package errutil_test
import (
"context"
"errors"
"fmt"
"path"
"strings"
"github.com/grafana/grafana/pkg/util/errutil"
)
var (
// define the set of errors which should be presented using the
// same error message for the frontend statically within the
// package.
errAbsPath = errutil.NewBase(errutil.StatusBadRequest, "shorturl.absolute-path")
errInvalidPath = errutil.NewBase(errutil.StatusBadRequest, "shorturl.invalid-path")
errUnexpected = errutil.NewBase(errutil.StatusInternal, "shorturl.unexpected")
)
func Example() {
var e errutil.Error
_, err := CreateShortURL("abc/../def")
errors.As(err, &e)
fmt.Println(e.Reason.Status().HTTPStatus(), e.MessageID)
fmt.Println(e.Error())
// Output:
// 400 shorturl.invalid-path
// [shorturl.invalid-path] path mustn't contain '..': 'abc/../def'
}
// CreateShortURL runs a few validations and returns
// 'https://example.org/s/tretton' if they all pass. It's not a very
// useful function, but it shows errors in a semi-realistic function.
func CreateShortURL(longURL string) (string, error) {
if path.IsAbs(longURL) {
return "", errAbsPath.Errorf("unexpected absolute path")
}
if strings.Contains(longURL, "../") {
return "", errInvalidPath.Errorf("path mustn't contain '..': '%s'", longURL)
}
if strings.Contains(longURL, "@") {
return "", errInvalidPath.Errorf("cannot shorten email addresses")
}
shortURL, err := createShortURL(context.Background(), longURL)
if err != nil {
return "", errUnexpected.Errorf("failed to create short URL: %w", err)
}
return shortURL, nil
}
func createShortURL(_ context.Context, _ string) (string, error) {
return "https://example.org/s/tretton", nil
}

37
pkg/util/errutil/log.go Normal file
View File

@ -0,0 +1,37 @@
package errutil
type LogLevel string
const (
LevelUnknown LogLevel = ""
LevelNever LogLevel = "never"
LevelDebug LogLevel = "debug"
LevelInfo LogLevel = "info"
LevelWarn LogLevel = "warn"
LevelError LogLevel = "error"
)
// LogInterface is a subset of github.com/grafana/grafana/pkg/infra/log.Logger
// to avoid having to depend on other packages in the module so that
// there's no risk of circular dependencies.
type LogInterface interface {
Debug(msg string, ctx ...interface{})
Info(msg string, ctx ...interface{})
Warn(msg string, ctx ...interface{})
Error(msg string, ctx ...interface{})
}
func (l LogLevel) LogFunc(logger LogInterface) func(msg string, ctx ...interface{}) {
switch l {
case LevelNever:
return func(_ string, _ ...interface{}) {}
case LevelDebug:
return logger.Debug
case LevelInfo:
return logger.Info
case LevelWarn:
return logger.Warn
default: // LevelUnknown and LevelError
return logger.Error
}
}

128
pkg/util/errutil/status.go Normal file
View File

@ -0,0 +1,128 @@
package errutil
import "net/http"
const (
// StatusUnknown implies an error that should be updated to contain
// an accurate status code, as none has been provided.
// HTTP status code 500.
StatusUnknown CoreStatus = ""
// StatusUnauthorized means that the server does not recognize the
// client's authentication, either because it has not been provided
// or is invalid for the operation.
// HTTP status code 401.
StatusUnauthorized CoreStatus = "Unauthorized"
// StatusForbidden means that the server refuses to perform the
// requested action for the authenticated uer.
// HTTP status code 403.
StatusForbidden CoreStatus = "Forbidden"
// StatusNotFound means that the server does not have any
// corresponding document to return to the request.
// HTTP status code 404.
StatusNotFound CoreStatus = "Not found"
// StatusTooManyRequests means that the client is rate limited
// by the server and should back-off before trying again.
// HTTP status code 429.
StatusTooManyRequests CoreStatus = "Too many requests"
// StatusBadRequest means that the server was unable to parse the
// parameters or payload for the request.
// HTTP status code 400.
StatusBadRequest CoreStatus = "Bad request"
// StatusValidationFailed means that the server was able to parse
// the payload for the request but it failed one or more validation
// checks.
// HTTP status code 400.
StatusValidationFailed CoreStatus = "Validation failed"
// StatusInternal means that the server acknowledges that there's
// an error, but that there is nothing the client can do to fix it.
// HTTP status code 500.
StatusInternal CoreStatus = "Internal server error"
// StatusTimeout means that the server did not complete the request
// within the required time and aborted the action.
// HTTP status code 504.
StatusTimeout CoreStatus = "Timeout"
// StatusNotImplemented means that the server does not support the
// requested action. Typically used during development of new
// features.
// HTTP status code 501.
StatusNotImplemented CoreStatus = "Not implemented"
)
// StatusReason allows for wrapping of CoreStatus.
type StatusReason interface {
Status() CoreStatus
}
type CoreStatus string
// Status implements the StatusReason interface.
func (s CoreStatus) Status() CoreStatus {
return s
}
// HTTPStatus converts the CoreStatus to an HTTP status code.
func (s CoreStatus) HTTPStatus() int {
switch s {
case StatusUnauthorized:
return http.StatusUnauthorized
case StatusForbidden:
return http.StatusForbidden
case StatusNotFound:
return http.StatusNotFound
case StatusTimeout:
return http.StatusGatewayTimeout
case StatusTooManyRequests:
return http.StatusTooManyRequests
case StatusBadRequest, StatusValidationFailed:
return http.StatusBadRequest
case StatusNotImplemented:
return http.StatusNotImplemented
case StatusUnknown, StatusInternal:
return http.StatusInternalServerError
default:
return http.StatusInternalServerError
}
}
// LogLevel returns the default LogLevel for the CoreStatus.
func (s CoreStatus) LogLevel() LogLevel {
switch s {
case StatusUnauthorized:
return LevelInfo
case StatusForbidden:
return LevelInfo
case StatusNotFound:
return LevelDebug
case StatusTimeout:
return LevelInfo
case StatusTooManyRequests:
return LevelInfo
case StatusBadRequest:
return LevelInfo
case StatusValidationFailed:
return LevelInfo
case StatusNotImplemented:
return LevelError
case StatusUnknown, StatusInternal:
return LevelError
default:
return LevelUnknown
}
}
// ProxyStatus implies that an error originated from the data source
// proxy.
type ProxyStatus CoreStatus
// Status implements the StatusReason interface.
func (s ProxyStatus) Status() CoreStatus {
return CoreStatus(s)
}
// PluginStatus implies that an error originated from a plugin.
type PluginStatus CoreStatus
// Status implements the StatusReason interface.
func (s PluginStatus) Status() CoreStatus {
return CoreStatus(s)
}

View File

@ -0,0 +1,117 @@
package errutil
import (
"bytes"
"fmt"
"text/template"
)
// Template is an extended Base for when using templating to construct
// error messages.
type Template struct {
Base Base
logTemplate *template.Template
publicTemplate *template.Template
}
// TemplateData contains data for constructing an Error based on a
// Template.
type TemplateData struct {
Private map[string]interface{}
Public map[string]interface{}
Error error
}
// Template provides templating for converting Base to Error.
// This is useful where the public payload is populated with fields that
// should be present in the internal error representation.
func (b Base) Template(pattern string, opts ...TemplateOpt) (Template, error) {
tmpl, err := template.New(b.messageID + "~private").Parse(pattern)
if err != nil {
return Template{}, err
}
t := Template{
Base: b,
logTemplate: tmpl,
}
for _, o := range opts {
t, err = o(t)
if err != nil {
return Template{}, err
}
}
return t, nil
}
type TemplateOpt func(Template) (Template, error)
// MustTemplate panics if the template for Template cannot be compiled.
//
// Only useful for global or package level initialization of Template:s.
func (b Base) MustTemplate(pattern string, opts ...TemplateOpt) Template {
res, err := b.Template(pattern, opts...)
if err != nil {
panic(err)
}
return res
}
// WithPublic provides templating for the user facing error message based
// on only the fields available in TemplateData.Public.
//
// Used as a functional option to Base.Template.
func WithPublic(pattern string) TemplateOpt {
return func(t Template) (Template, error) {
var err error
t.publicTemplate, err = template.New(t.Base.messageID + "~public").Parse(pattern)
return t, err
}
}
// WithPublicFromLog copies over the template for the log message to be
// used for the user facing error message.
// TemplateData.Error and TemplateData.Private will not be populated
// when rendering the public message.
//
// Used as a functional option to Base.Template.
func WithPublicFromLog() TemplateOpt {
return func(t Template) (Template, error) {
t.publicTemplate = t.logTemplate
return t, nil
}
}
// Build returns a new Error based on the base Template and the provided
// TemplateData, wrapping the error in TemplateData.Error if set.
//
// Build can fail and return an error that is not of type Error.
func (t Template) Build(data TemplateData) error {
if t.logTemplate == nil {
return fmt.Errorf("cannot initialize error using missing template")
}
buf := bytes.Buffer{}
err := t.logTemplate.Execute(&buf, data)
if err != nil {
return err
}
pubBuf := bytes.Buffer{}
if t.publicTemplate != nil {
err := t.publicTemplate.Execute(&pubBuf, TemplateData{Public: data.Public})
if err != nil {
return err
}
}
e := t.Base.Errorf("%s", buf.String())
e.PublicMessage = pubBuf.String()
e.PublicPayload = data.Public
e.Underlying = data.Error
return e
}

View File

@ -0,0 +1,54 @@
package errutil_test
import (
"errors"
"fmt"
"github.com/grafana/grafana/pkg/util/errutil"
)
func ExampleTemplate() {
// Initialization, this is typically done on a package or global
// level.
var tmpl = errutil.NewBase(errutil.StatusInternal, "template.sample-error").MustTemplate("[{{ .Public.user }}] got error: {{ .Error }}")
// Construct an error based on the template.
err := tmpl.Build(errutil.TemplateData{
Public: map[string]interface{}{
"user": "grot the bot",
},
Error: errors.New("oh noes"),
})
fmt.Println(err.Error())
// Output:
// [template.sample-error] [grot the bot] got error: oh noes
}
func ExampleTemplate_public() {
// Initialization, this is typically done on a package or global
// level.
var tmpl = errutil.
NewBase(errutil.StatusInternal, "template.sample-error").
MustTemplate(
"[{{ .Public.user }}] got error: {{ .Error }}",
errutil.WithPublic("Oh, no, error for {{ .Public.user }}"),
)
// Construct an error based on the template.
//nolint:errorlint
err := tmpl.Build(errutil.TemplateData{
Public: map[string]interface{}{
"user": "grot the bot",
},
Error: errors.New("oh noes"),
}).(errutil.Error)
fmt.Println(err.Error())
fmt.Println(err.PublicMessage)
// Output:
// [template.sample-error] [grot the bot] got error: oh noes
// Oh, no, error for grot the bot
}