From 264c2a9d1ed9c798bdc590878e640a988a433220 Mon Sep 17 00:00:00 2001 From: Emil Tullstedt Date: Tue, 14 Jun 2022 10:50:11 +0200 Subject: [PATCH] Errors: Introduce error type with Grafana specific metadata (#47504) --- pkg/api/response/response.go | 29 ++++- pkg/util/errutil/doc.go | 44 +++++++ pkg/util/errutil/errors.go | 165 ++++++++++++++++++++++++ pkg/util/errutil/errors_example_test.go | 60 +++++++++ pkg/util/errutil/log.go | 37 ++++++ pkg/util/errutil/status.go | 128 ++++++++++++++++++ pkg/util/errutil/template.go | 117 +++++++++++++++++ pkg/util/errutil/template_test.go | 54 ++++++++ 8 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 pkg/util/errutil/doc.go create mode 100644 pkg/util/errutil/errors.go create mode 100644 pkg/util/errutil/errors_example_test.go create mode 100644 pkg/util/errutil/log.go create mode 100644 pkg/util/errutil/status.go create mode 100644 pkg/util/errutil/template.go create mode 100644 pkg/util/errutil/template_test.go diff --git a/pkg/api/response/response.go b/pkg/api/response/response.go index e08ed4eb974..604a84dd6da 100644 --- a/pkg/api/response/response.go +++ b/pkg/api/response/response.go @@ -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) diff --git a/pkg/util/errutil/doc.go b/pkg/util/errutil/doc.go new file mode 100644 index 00000000000..a1742adc96f --- /dev/null +++ b/pkg/util/errutil/doc.go @@ -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 diff --git a/pkg/util/errutil/errors.go b/pkg/util/errutil/errors.go new file mode 100644 index 00000000000..555bb639d9d --- /dev/null +++ b/pkg/util/errutil/errors.go @@ -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, + } +} diff --git a/pkg/util/errutil/errors_example_test.go b/pkg/util/errutil/errors_example_test.go new file mode 100644 index 00000000000..fb85f9b2967 --- /dev/null +++ b/pkg/util/errutil/errors_example_test.go @@ -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 +} diff --git a/pkg/util/errutil/log.go b/pkg/util/errutil/log.go new file mode 100644 index 00000000000..58f008131eb --- /dev/null +++ b/pkg/util/errutil/log.go @@ -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 + } +} diff --git a/pkg/util/errutil/status.go b/pkg/util/errutil/status.go new file mode 100644 index 00000000000..379c23e54c3 --- /dev/null +++ b/pkg/util/errutil/status.go @@ -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) +} diff --git a/pkg/util/errutil/template.go b/pkg/util/errutil/template.go new file mode 100644 index 00000000000..06954ba5b33 --- /dev/null +++ b/pkg/util/errutil/template.go @@ -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 +} diff --git a/pkg/util/errutil/template_test.go b/pkg/util/errutil/template_test.go new file mode 100644 index 00000000000..1e0fa48b147 --- /dev/null +++ b/pkg/util/errutil/template_test.go @@ -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 +}