Chore: Move identity and errutil to apimachinery module (#89116)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// 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 structure of the public
|
||||
// message attached to the specific error.
|
||||
//
|
||||
// var errNotFound = errutil.NewBase(errutil.StatusNotFound, "service.notFound")
|
||||
//
|
||||
// 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.notFound", WithPublicMessage("The thing is missing."))
|
||||
//
|
||||
// If a dynamic message is needed, the [Template] type extends Base with
|
||||
// a Go template using [text/template], 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
|
||||
@@ -0,0 +1,471 @@
|
||||
package errutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
errorsK8s "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
// 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
|
||||
source Source
|
||||
}
|
||||
|
||||
// NewBase initializes a [Base] that is used to construct [Error].
|
||||
// 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.errorBrief, for example
|
||||
//
|
||||
// login.failedAuthentication
|
||||
// dashboards.validationError
|
||||
// dashboards.uidAlreadyExists
|
||||
func NewBase(reason StatusReason, msgID string, opts ...BaseOpt) Base {
|
||||
b := Base{
|
||||
reason: reason,
|
||||
messageID: msgID,
|
||||
logLevel: reason.Status().LogLevel(),
|
||||
source: SourceServer,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
b = opt(b)
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// NotFound initializes a new [Base] error with reason StatusNotFound
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// folder.notFound
|
||||
// plugin.notRegistered
|
||||
func NotFound(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusNotFound, msgID, opts...)
|
||||
}
|
||||
|
||||
// UnprocessableContent initializes a new [Base] error with reason StatusUnprocessableEntity
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// plugin.checksumMismatch
|
||||
func UnprocessableEntity(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusUnprocessableEntity, msgID, opts...)
|
||||
}
|
||||
|
||||
// Conflict initializes a new [Base] error with reason StatusConflict
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// folder.alreadyExists
|
||||
func Conflict(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusConflict, msgID, opts...)
|
||||
}
|
||||
|
||||
// BadRequest initializes a new [Base] error with reason StatusBadRequest
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// query.invalidDatasourceId
|
||||
// sse.dataQueryError
|
||||
func BadRequest(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusBadRequest, msgID, opts...)
|
||||
}
|
||||
|
||||
// ValidationFailed initializes a new [Base] error with reason StatusValidationFailed
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// datasource.nameInvalid
|
||||
// datasource.urlInvalid
|
||||
// serviceaccounts.errInvalidInput
|
||||
func ValidationFailed(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusValidationFailed, msgID, opts...)
|
||||
}
|
||||
|
||||
// Internal initializes a new [Base] error with reason StatusInternal
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// sqleng.connectionError
|
||||
// plugin.downstreamError
|
||||
func Internal(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusInternal, msgID, opts...)
|
||||
}
|
||||
|
||||
// Timeout initializes a new [Base] error with reason StatusTimeout.
|
||||
//
|
||||
// area.timeout
|
||||
func Timeout(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusTimeout, msgID, opts...)
|
||||
}
|
||||
|
||||
// Unauthorized initializes a new [Base] error with reason StatusUnauthorized
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// auth.unauthorized
|
||||
func Unauthorized(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusUnauthorized, msgID, opts...)
|
||||
}
|
||||
|
||||
// Forbidden initializes a new [Base] error with reason StatusForbidden
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// quota.disabled
|
||||
// user.sync.forbidden
|
||||
func Forbidden(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusForbidden, msgID, opts...)
|
||||
}
|
||||
|
||||
// TooManyRequests initializes a new [Base] error with reason StatusTooManyRequests
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// area.tooManyRequests
|
||||
func TooManyRequests(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusTooManyRequests, msgID, opts...)
|
||||
}
|
||||
|
||||
// ClientClosedRequest initializes a new [Base] error with reason StatusClientClosedRequest
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// plugin.requestCanceled
|
||||
func ClientClosedRequest(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusClientClosedRequest, msgID, opts...)
|
||||
}
|
||||
|
||||
// NotImplemented initializes a new [Base] error with reason StatusNotImplemented
|
||||
// that is used to construct [Error]. The msgID is passed to the caller
|
||||
// to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// plugin.notImplemented
|
||||
// auth.identity.unsupported
|
||||
func NotImplemented(msgID string, opts ...BaseOpt) Base {
|
||||
return NewBase(StatusNotImplemented, msgID, opts...)
|
||||
}
|
||||
|
||||
// BadGateway initializes a new [Base] error with reason StatusBadGateway
|
||||
// and source SourceDownstream that is used to construct [Error]. The msgID
|
||||
// is passed to the caller to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// area.downstreamError
|
||||
func BadGateway(msgID string, opts ...BaseOpt) Base {
|
||||
newOpts := []BaseOpt{WithDownstream()}
|
||||
newOpts = append(newOpts, opts...)
|
||||
return NewBase(StatusBadGateway, msgID, newOpts...)
|
||||
}
|
||||
|
||||
// GatewayTimeout initializes a new [Base] error with reason StatusGatewayTimeout
|
||||
// and source SourceDownstream that is used to construct [Error]. The msgID
|
||||
// is passed to the caller to serve as the base for user facing error messages.
|
||||
//
|
||||
// msgID should be structured as component.errorBrief, for example
|
||||
//
|
||||
// area.downstreamTimeout
|
||||
func GatewayTimeout(msgID string, opts ...BaseOpt) Base {
|
||||
newOpts := []BaseOpt{WithDownstream()}
|
||||
newOpts = append(newOpts, opts...)
|
||||
return NewBase(StatusGatewayTimeout, msgID, newOpts...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// WithDownstream sets the source as SourceDownstream that will be used
|
||||
// for errors based on this [Base].
|
||||
//
|
||||
// Used as a functional option to [NewBase].
|
||||
func WithDownstream() BaseOpt {
|
||||
return func(b Base) Base {
|
||||
b.source = SourceDownstream
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// Errorf creates a new [Error] with 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 ...any) 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,
|
||||
Source: b.source,
|
||||
}
|
||||
}
|
||||
|
||||
// Error makes Base implement the error type. Relying on this is
|
||||
// discouraged, as the Error type can carry additional information
|
||||
// that's valuable when debugging.
|
||||
func (b Base) Error() string {
|
||||
return b.Errorf("").Error()
|
||||
}
|
||||
|
||||
func (b Base) Status() StatusReason {
|
||||
if b.reason == nil {
|
||||
return StatusUnknown
|
||||
}
|
||||
return b.reason.Status()
|
||||
}
|
||||
|
||||
// Is validates that an [Error] has the same reason and messageID as the
|
||||
// Base.
|
||||
//
|
||||
// Implements the interface used by [errors.Is].
|
||||
func (b Base) Is(err 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
|
||||
base, isBase := err.(Base)
|
||||
//nolint:errorlint
|
||||
gfErr, isGrafanaError := err.(Error)
|
||||
|
||||
switch {
|
||||
case isGrafanaError:
|
||||
return b.reason == gfErr.Reason && b.messageID == gfErr.MessageID
|
||||
case isBase:
|
||||
return b.reason == base.reason && b.messageID == base.messageID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Allow errorutil errors to be returned as informative k8s errors
|
||||
var _ = errorsK8s.APIStatus(&Error{})
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Use [Base.Errorf] or [Template.Build] to construct errors:
|
||||
//
|
||||
// // package-level
|
||||
// var errMonthlyQuota = NewBase(errutil.StatusTooManyRequests, "service.monthlyQuotaReached")
|
||||
// // in function
|
||||
// err := errMonthlyQuota.Errorf("user '%s' reached their monthly quota for service", userUID)
|
||||
//
|
||||
// or
|
||||
//
|
||||
// // package-level
|
||||
// var errRateLimited = NewBase(errutil.StatusTooManyRequests, "service.backoff").MustTemplate(
|
||||
// "quota reached for user {{ .Private.user }}, rate limited until {{ .Public.time }}",
|
||||
// errutil.WithPublic("Too many requests, try again after {{ .Public.time }}"),
|
||||
// )
|
||||
// // in function
|
||||
// err := errRateLimited.Build(TemplateData{
|
||||
// Private: map[string]interface{ "user": userUID },
|
||||
// Public: map[string]interface{ "time": rateLimitUntil },
|
||||
// })
|
||||
//
|
||||
// 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 provides the Grafana abstracted reason which can be turned
|
||||
// into an upstream status code depending on the protocol. This
|
||||
// allows us to use the same errors across HTTP, gRPC, and other
|
||||
// protocols.
|
||||
Reason StatusReason
|
||||
// A MessageID together with PublicPayload should suffice to
|
||||
// create the PublicMessage. This lets a localization aware client
|
||||
// construct messages based on structured data.
|
||||
MessageID string
|
||||
// LogMessage will be displayed in the server logs or wherever
|
||||
// [Error.Error] is called.
|
||||
LogMessage string
|
||||
// Underlying is the wrapped error returned by [Error.Unwrap].
|
||||
Underlying error
|
||||
// PublicMessage is constructed from the template uniquely
|
||||
// identified by MessageID and the values in PublicPayload (if any)
|
||||
// to provide the end-user with information that they can use to
|
||||
// resolve the issue.
|
||||
PublicMessage string
|
||||
// PublicPayload provides fields for passing structured data to
|
||||
// construct localized error messages in the client.
|
||||
PublicPayload map[string]any
|
||||
// LogLevel provides a suggested level of logging for the error.
|
||||
LogLevel LogLevel
|
||||
// Source identifies from where the error originates.
|
||||
Source Source
|
||||
}
|
||||
|
||||
// MarshalJSON returns an error, we do not want raw [Error]s being
|
||||
// marshaled into JSON.
|
||||
//
|
||||
// Use [Error.Public] to convert the Error into a [PublicError] which
|
||||
// can safely be marshaled into JSON. 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)
|
||||
}
|
||||
|
||||
// When the error is rendered by an apiserver, this format is used
|
||||
func (e Error) Status() metav1.Status {
|
||||
public := e.Public()
|
||||
s := metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: int32(public.StatusCode),
|
||||
Reason: metav1.StatusReason(e.Reason.Status()), // almost true
|
||||
Message: public.Message,
|
||||
}
|
||||
|
||||
// Shove the extra data into details
|
||||
if public.Extra != nil || public.MessageID != "" {
|
||||
s.Details = &metav1.StatusDetails{
|
||||
UID: types.UID(public.MessageID),
|
||||
}
|
||||
for k, v := range public.Extra {
|
||||
v, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
s.Details.Causes = append(s.Details.Causes, metav1.StatusCause{
|
||||
Field: k,
|
||||
Message: string(v),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 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 checks whether an error is derived from the error passed as an
|
||||
// argument.
|
||||
//
|
||||
// Implements the interface used by [errors.Is].
|
||||
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, isGrafanaError := other.(Error)
|
||||
//nolint:errorlint
|
||||
base, isBase := other.(Base)
|
||||
//nolint:errorlint
|
||||
templateErr, isTemplateErr := other.(Template)
|
||||
|
||||
switch {
|
||||
case isGrafanaError:
|
||||
return o.Reason == e.Reason && o.MessageID == e.MessageID && o.Error() == e.Error()
|
||||
case isBase:
|
||||
return base.Is(e)
|
||||
case isTemplateErr:
|
||||
return templateErr.Base.Is(e)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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]any `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,
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (p PublicError) Error() string {
|
||||
return fmt.Sprintf("[%s] %s", p.MessageID, p.Message)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package errutil_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/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.BadRequest("shorturl.absolutePath")
|
||||
errInvalidPath = errutil.BadRequest("shorturl.invalidPath")
|
||||
errUnexpected = errutil.Internal("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.invalidPath
|
||||
// [shorturl.invalidPath] 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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package errutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBase_Is(t *testing.T) {
|
||||
baseNotFound := NotFound("test.notFound")
|
||||
baseInternal := Internal("test.internal")
|
||||
|
||||
tests := []struct {
|
||||
Base Base
|
||||
Other error
|
||||
Expect bool
|
||||
ExpectUnwrapped bool
|
||||
}{
|
||||
{
|
||||
Base: Base{},
|
||||
Other: errors.New(""),
|
||||
Expect: false,
|
||||
},
|
||||
{
|
||||
Base: Base{},
|
||||
Other: Base{},
|
||||
Expect: true,
|
||||
},
|
||||
{
|
||||
Base: Base{},
|
||||
Other: Error{},
|
||||
Expect: true,
|
||||
},
|
||||
{
|
||||
Base: baseNotFound,
|
||||
Other: baseNotFound,
|
||||
Expect: true,
|
||||
},
|
||||
{
|
||||
Base: baseNotFound,
|
||||
Other: baseNotFound.Errorf("this is an error derived from baseNotFound, it is considered to be equal to baseNotFound"),
|
||||
Expect: true,
|
||||
},
|
||||
{
|
||||
Base: baseNotFound,
|
||||
Other: baseInternal,
|
||||
Expect: false,
|
||||
},
|
||||
{
|
||||
Base: baseInternal,
|
||||
Other: fmt.Errorf("wrapped, like a burrito: %w", baseInternal.Errorf("oh noes")),
|
||||
Expect: false,
|
||||
ExpectUnwrapped: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf(
|
||||
"Base '%s' == '%s' of type %s = %v (%v unwrapped)",
|
||||
tc.Base.Error(),
|
||||
tc.Other.Error(),
|
||||
reflect.TypeOf(tc.Other),
|
||||
tc.Expect,
|
||||
tc.Expect || tc.ExpectUnwrapped,
|
||||
), func(t *testing.T) {
|
||||
assert.Equal(t, tc.Expect, tc.Base.Is(tc.Other), "direct comparison")
|
||||
assert.Equal(t, tc.Expect, errors.Is(tc.Base, tc.Other), "comparison using errors.Is with other as target")
|
||||
assert.Equal(t, tc.Expect || tc.ExpectUnwrapped, errors.Is(tc.Other, tc.Base), "comparison using errors.Is with base as target, should unwrap other")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package errutil
|
||||
|
||||
import "context"
|
||||
|
||||
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 ...any)
|
||||
Info(msg string, ctx ...any)
|
||||
Warn(msg string, ctx ...any)
|
||||
Error(msg string, ctx ...any)
|
||||
}
|
||||
|
||||
func (l LogLevel) LogFunc(logger LogInterface) func(msg string, ctx ...any) {
|
||||
switch l {
|
||||
case LevelNever:
|
||||
return func(_ string, _ ...any) {}
|
||||
case LevelDebug:
|
||||
return logger.Debug
|
||||
case LevelInfo:
|
||||
return logger.Info
|
||||
case LevelWarn:
|
||||
return logger.Warn
|
||||
default: // LevelUnknown and LevelError.
|
||||
return logger.Error
|
||||
}
|
||||
}
|
||||
|
||||
func (l LogLevel) HighestOf(other LogLevel) LogLevel {
|
||||
if l.order() < other.order() {
|
||||
return other
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l LogLevel) order() int {
|
||||
switch l {
|
||||
case LevelNever:
|
||||
return 0
|
||||
case LevelDebug:
|
||||
return 1
|
||||
case LevelInfo:
|
||||
return 2
|
||||
case LevelWarn:
|
||||
return 3
|
||||
default: // LevelUnknown and LevelError.
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
type useUnifiedLogging struct{}
|
||||
|
||||
func SetUnifiedLogging(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, useUnifiedLogging{}, true)
|
||||
}
|
||||
|
||||
func HasUnifiedLogging(ctx context.Context) bool {
|
||||
v, ok := ctx.Value(useUnifiedLogging{}).(bool)
|
||||
return ok && v
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package errutil
|
||||
|
||||
// Source identifies from where an error originates.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
// SourceServer implies error originates from within the server, i.e. this application.
|
||||
SourceServer Source = "server"
|
||||
|
||||
// SourceDownstream implies error originates from response error while server acting
|
||||
// as a proxy, i.e. from a downstream service.
|
||||
SourceDownstream Source = "downstream"
|
||||
)
|
||||
|
||||
// IsDownstream checks if Source is SourceDownstream.
|
||||
func (s Source) IsDownstream() bool {
|
||||
return s == SourceDownstream
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package errutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
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 = CoreStatus(metav1.StatusReasonUnauthorized)
|
||||
// StatusForbidden means that the server refuses to perform the
|
||||
// requested action for the authenticated uer.
|
||||
// HTTP status code 403.
|
||||
StatusForbidden CoreStatus = CoreStatus(metav1.StatusReasonForbidden)
|
||||
// StatusNotFound means that the server does not have any
|
||||
// corresponding document to return to the request.
|
||||
// HTTP status code 404.
|
||||
StatusNotFound CoreStatus = CoreStatus(metav1.StatusReasonNotFound)
|
||||
// StatusUnprocessableEntity means that the server understands the request,
|
||||
// the content type and the syntax but it was unable to process the
|
||||
// contained instructions.
|
||||
// HTTP status code 422.
|
||||
StatusUnprocessableEntity CoreStatus = "Unprocessable Entity"
|
||||
// StatusConflict means that the server cannot fulfill the request
|
||||
// there is a conflict in the current state of a resource
|
||||
// HTTP status code 409.
|
||||
StatusConflict CoreStatus = CoreStatus(metav1.StatusReasonConflict)
|
||||
// StatusTooManyRequests means that the client is rate limited
|
||||
// by the server and should back-off before trying again.
|
||||
// HTTP status code 429.
|
||||
StatusTooManyRequests CoreStatus = CoreStatus(metav1.StatusReasonTooManyRequests)
|
||||
// StatusBadRequest means that the server was unable to parse the
|
||||
// parameters or payload for the request.
|
||||
// HTTP status code 400.
|
||||
StatusBadRequest CoreStatus = CoreStatus(metav1.StatusReasonBadRequest)
|
||||
// StatusClientClosedRequest means that a client closes the connection
|
||||
// while the server is processing the request.
|
||||
//
|
||||
// This is a non-standard HTTP status code introduced by nginx, see
|
||||
// https://httpstatus.in/499/ for more information.
|
||||
// HTTP status code 499.
|
||||
StatusClientClosedRequest CoreStatus = "Client closed 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 = CoreStatus(metav1.StatusReasonInternalError)
|
||||
// StatusTimeout means that the server did not complete the request
|
||||
// within the required time and aborted the action.
|
||||
// HTTP status code 504.
|
||||
StatusTimeout CoreStatus = CoreStatus(metav1.StatusReasonTimeout)
|
||||
// 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"
|
||||
// StatusBadGateway means that the server, while acting as a proxy,
|
||||
// received an invalid response from the downstream server.
|
||||
// HTTP status code 502.
|
||||
StatusBadGateway CoreStatus = "Bad gateway"
|
||||
// StatusGatewayTimeout means that the server, while acting as a proxy,
|
||||
// did not receive a timely response from a downstream server it needed
|
||||
// to access in order to complete the request.
|
||||
// HTTP status code 504.
|
||||
StatusGatewayTimeout CoreStatus = "Gateway timeout"
|
||||
)
|
||||
|
||||
// HTTPStatusClientClosedRequest A non-standard status code introduced by nginx
|
||||
// for the case when a client closes the connection while nginx is processing
|
||||
// the request. See https://httpstatus.in/499/ for more information.
|
||||
const HTTPStatusClientClosedRequest = 499
|
||||
|
||||
// StatusReason allows for wrapping of CoreStatus.
|
||||
type StatusReason interface {
|
||||
Status() CoreStatus
|
||||
}
|
||||
|
||||
type CoreStatus metav1.StatusReason
|
||||
|
||||
// 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, StatusGatewayTimeout:
|
||||
return http.StatusGatewayTimeout
|
||||
case StatusUnprocessableEntity:
|
||||
return http.StatusUnprocessableEntity
|
||||
case StatusConflict:
|
||||
return http.StatusConflict
|
||||
case StatusTooManyRequests:
|
||||
return http.StatusTooManyRequests
|
||||
case StatusBadRequest, StatusValidationFailed:
|
||||
return http.StatusBadRequest
|
||||
case StatusClientClosedRequest:
|
||||
return HTTPStatusClientClosedRequest
|
||||
case StatusNotImplemented:
|
||||
return http.StatusNotImplemented
|
||||
case StatusBadGateway:
|
||||
return http.StatusBadGateway
|
||||
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 LevelInfo
|
||||
case StatusTimeout:
|
||||
return LevelInfo
|
||||
case StatusUnprocessableEntity:
|
||||
return LevelInfo
|
||||
case StatusConflict:
|
||||
return LevelInfo
|
||||
case StatusTooManyRequests:
|
||||
return LevelInfo
|
||||
case StatusBadRequest:
|
||||
return LevelInfo
|
||||
case StatusValidationFailed:
|
||||
return LevelInfo
|
||||
case StatusNotImplemented:
|
||||
return LevelDebug
|
||||
case StatusUnknown, StatusInternal:
|
||||
return LevelError
|
||||
default:
|
||||
return LevelUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (s CoreStatus) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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]any
|
||||
Public map[string]any
|
||||
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].
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
func (t Template) Error() string {
|
||||
return t.Base.Error()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package errutil_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
)
|
||||
|
||||
func TestTemplate(t *testing.T) {
|
||||
tmpl := errutil.Internal("template.sampleError").MustTemplate("[{{ .Public.user }}] got error: {{ .Error }}")
|
||||
err := tmpl.Build(errutil.TemplateData{
|
||||
Public: map[string]any{
|
||||
"user": "grot the bot",
|
||||
},
|
||||
Error: errors.New("oh noes"),
|
||||
})
|
||||
|
||||
t.Run("Built error should return true when compared with templated error ", func(t *testing.T) {
|
||||
require.True(t, errors.Is(err, tmpl))
|
||||
})
|
||||
|
||||
t.Run("Built error should return true when compared with templated error base ", func(t *testing.T) {
|
||||
require.True(t, errors.Is(err, tmpl.Base))
|
||||
})
|
||||
}
|
||||
|
||||
func ExampleTemplate() {
|
||||
// Initialization, this is typically done on a package or global
|
||||
// level.
|
||||
var tmpl = errutil.Internal("template.sampleError").MustTemplate("[{{ .Public.user }}] got error: {{ .Error }}")
|
||||
|
||||
// Construct an error based on the template.
|
||||
err := tmpl.Build(errutil.TemplateData{
|
||||
Public: map[string]any{
|
||||
"user": "grot the bot",
|
||||
},
|
||||
Error: errors.New("oh noes"),
|
||||
})
|
||||
|
||||
fmt.Println(err.Error())
|
||||
|
||||
// Output:
|
||||
// [template.sampleError] [grot the bot] got error: oh noes
|
||||
}
|
||||
|
||||
func ExampleTemplate_public() {
|
||||
// Initialization, this is typically done on a package or global
|
||||
// level.
|
||||
var tmpl = errutil.Internal("template.sampleError").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]any{
|
||||
"user": "grot the bot",
|
||||
},
|
||||
Error: errors.New("oh noes"),
|
||||
}).(errutil.Error)
|
||||
|
||||
fmt.Println(err.Error())
|
||||
fmt.Println(err.PublicMessage)
|
||||
|
||||
// Output:
|
||||
// [template.sampleError] [grot the bot] got error: oh noes
|
||||
// Oh, no, error for grot the bot
|
||||
}
|
||||
Reference in New Issue
Block a user