Skip to content

Commit

Permalink
Error source HTTP client middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
marefr committed Oct 10, 2024
1 parent 6e35428 commit bc0110f
Show file tree
Hide file tree
Showing 12 changed files with 490 additions and 265 deletions.
9 changes: 5 additions & 4 deletions backend/data_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"github.com/grafana/grafana-plugin-sdk-go/backend/status"
"github.com/grafana/grafana-plugin-sdk-go/genproto/pluginv2"
)

Expand All @@ -29,9 +30,9 @@ func (a *dataSDKAdapter) QueryData(ctx context.Context, req *pluginv2.QueryDataR
var innerErr error
resp, innerErr = a.queryDataHandler.QueryData(ctx, parsedReq)

status := RequestStatusFromQueryDataResponse(resp, innerErr)
requestStatus := RequestStatusFromQueryDataResponse(resp, innerErr)
if innerErr != nil {
return status, innerErr
return requestStatus, innerErr
} else if resp == nil {
return RequestStatusError, errors.New("both response and error are nil, but one must be provided")
}
Expand All @@ -41,7 +42,7 @@ func (a *dataSDKAdapter) QueryData(ctx context.Context, req *pluginv2.QueryDataR
// and if there's no plugin error
var hasPluginError, hasDownstreamError bool
for refID, r := range resp.Responses {
if r.Error == nil || isCancelledError(r.Error) {
if r.Error == nil || status.IsCancelledError(r.Error) {
continue
}

Expand Down Expand Up @@ -81,7 +82,7 @@ func (a *dataSDKAdapter) QueryData(ctx context.Context, req *pluginv2.QueryDataR
}
}

return status, nil
return requestStatus, nil
})
if err != nil {
return nil, err
Expand Down
118 changes: 25 additions & 93 deletions backend/error_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,144 +2,76 @@ package backend

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/grafana/grafana-plugin-sdk-go/backend/status"
)

// ErrorSource type defines the source of the error
type ErrorSource string
type ErrorSource = status.Source

const (
// ErrorSourcePlugin error originates from plugin.
ErrorSourcePlugin ErrorSource = "plugin"
ErrorSourcePlugin = status.SourcePlugin

// ErrorSourceDownstream error originates from downstream service.
ErrorSourceDownstream ErrorSource = "downstream"
ErrorSourceDownstream = status.SourceDownstream

// DefaultErrorSource is the default [ErrorSource] that should be used when it is not explicitly set.
DefaultErrorSource ErrorSource = ErrorSourcePlugin
DefaultErrorSource = status.SourcePlugin
)

func (es ErrorSource) IsValid() bool {
return es == ErrorSourceDownstream || es == ErrorSourcePlugin
// ErrorSourceFromHTTPError returns an [ErrorSource] based on provided error.
func ErrorSourceFromHTTPError(err error) ErrorSource {
return status.SourceFromHTTPError(err)
}

// ErrorSourceFromStatus returns an [ErrorSource] based on provided HTTP status code.
// ErrorSourceFromHTTPStatus returns an [ErrorSource] based on provided HTTP status code.
func ErrorSourceFromHTTPStatus(statusCode int) ErrorSource {
switch statusCode {
case http.StatusMethodNotAllowed,
http.StatusNotAcceptable,
http.StatusPreconditionFailed,
http.StatusRequestEntityTooLarge,
http.StatusRequestHeaderFieldsTooLarge,
http.StatusRequestURITooLong,
http.StatusExpectationFailed,
http.StatusUpgradeRequired,
http.StatusRequestedRangeNotSatisfiable,
http.StatusNotImplemented:
return ErrorSourcePlugin
}

return ErrorSourceDownstream
return status.SourceFromHTTPStatus(statusCode)
}

type errorWithSourceImpl struct {
source ErrorSource
err error
// IsDownstreamError return true if provided error is an error with downstream source or
// a timeout error or a cancelled error.
func IsDownstreamError(err error) bool {
return status.IsDownstreamError(err)
}

func IsDownstreamError(err error) bool {
e := errorWithSourceImpl{
source: ErrorSourceDownstream,
}
if errors.Is(err, e) {
return true
}

type errorWithSource interface {
ErrorSource() ErrorSource
}

// nolint:errorlint
if errWithSource, ok := err.(errorWithSource); ok && errWithSource.ErrorSource() == ErrorSourceDownstream {
return true
}

if isHTTPTimeoutError(err) || isCancelledError(err) {
return true
}

return false
// IsDownstreamError return true if provided error is an error with downstream source or
// a HTTP timeout error or a cancelled error or a connection reset/refused error or dns not found error.
func IsDownstreamHTTPError(err error) bool {
return status.IsDownstreamHTTPError(err)
}

func DownstreamError(err error) error {
return errorWithSourceImpl{
source: ErrorSourceDownstream,
err: err,
}
return status.DownstreamError(err)
}

func DownstreamErrorf(format string, a ...any) error {
return DownstreamError(fmt.Errorf(format, a...))
}

func (e errorWithSourceImpl) ErrorSource() ErrorSource {
return e.source
}

func (e errorWithSourceImpl) Error() string {
return fmt.Errorf("%s error: %w", e.source, e.err).Error()
}

// Implements the interface used by [errors.Is].
func (e errorWithSourceImpl) Is(err error) bool {
if errWithSource, ok := err.(errorWithSourceImpl); ok {
return errWithSource.ErrorSource() == e.source
}

return false
}

func (e errorWithSourceImpl) Unwrap() error {
return e.err
}

type errorSourceCtxKey struct{}

// errorSourceFromContext returns the error source stored in the context.
// If no error source is stored in the context, [DefaultErrorSource] is returned.
func errorSourceFromContext(ctx context.Context) ErrorSource {
value, ok := ctx.Value(errorSourceCtxKey{}).(*ErrorSource)
if ok {
return *value
}
return DefaultErrorSource
return status.SourceFromContext(ctx)
}

// initErrorSource initialize the status source for the context.
// initErrorSource initialize the error source for the context.
func initErrorSource(ctx context.Context) context.Context {
s := DefaultErrorSource
return context.WithValue(ctx, errorSourceCtxKey{}, &s)
return status.InitSource(ctx)
}

// WithErrorSource mutates the provided context by setting the error source to
// s. If the provided context does not have a error source, the context
// will not be mutated and an error returned. This means that [initErrorSource]
// has to be called before this function.
func WithErrorSource(ctx context.Context, s ErrorSource) error {
v, ok := ctx.Value(errorSourceCtxKey{}).(*ErrorSource)
if !ok {
return errors.New("the provided context does not have a status source")
}
*v = s
return nil
return status.WithSource(ctx, s)
}

// WithDownstreamErrorSource mutates the provided context by setting the error source to
// [ErrorSourceDownstream]. If the provided context does not have a error source, the context
// will not be mutated and an error returned. This means that [initErrorSource] has to be
// called before this function.
func WithDownstreamErrorSource(ctx context.Context) error {
return WithErrorSource(ctx, ErrorSourceDownstream)
return status.WithDownstreamSource(ctx)
}
142 changes: 9 additions & 133 deletions backend/error_source_test.go
Original file line number Diff line number Diff line change
@@ -1,142 +1,18 @@
package backend
package backend_test

import (
"context"
"errors"
"fmt"
"net"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func TestErrorSource(t *testing.T) {
var es ErrorSource
require.False(t, es.IsValid())
require.True(t, ErrorSourceDownstream.IsValid())
require.True(t, ErrorSourcePlugin.IsValid())
}

func TestIsDownstreamError(t *testing.T) {
tcs := []struct {
name string
err error
expected bool
}{
{
name: "nil",
err: nil,
expected: false,
},
{
name: "downstream error",
err: DownstreamError(nil),
expected: true,
},
{
name: "timeout network error",
err: newFakeNetworkError(true, false),
expected: true,
},
{
name: "wrapped timeout network error",
err: fmt.Errorf("oh no. err %w", newFakeNetworkError(true, false)),
expected: true,
},
{
name: "temporary timeout network error",
err: newFakeNetworkError(true, true),
expected: true,
},
{
name: "non-timeout network error",
err: newFakeNetworkError(false, false),
expected: false,
},
{
name: "os.ErrDeadlineExceeded",
err: os.ErrDeadlineExceeded,
expected: true,
},
{
name: "os.ErrDeadlineExceeded",
err: fmt.Errorf("error: %w", os.ErrDeadlineExceeded),
expected: true,
},
{
name: "wrapped os.ErrDeadlineExceeded",
err: errors.Join(fmt.Errorf("oh no"), os.ErrDeadlineExceeded),
expected: true,
},
{
name: "other error",
err: fmt.Errorf("other error"),
expected: false,
},
{
name: "context.Canceled",
err: context.Canceled,
expected: true,
},
{
name: "wrapped context.Canceled",
err: fmt.Errorf("error: %w", context.Canceled),
expected: true,
},
{
name: "joined context.Canceled",
err: errors.Join(fmt.Errorf("oh no"), context.Canceled),
expected: true,
},
{
name: "gRPC canceled error",
err: status.Error(codes.Canceled, "canceled"),
expected: true,
},
{
name: "wrapped gRPC canceled error",
err: fmt.Errorf("error: %w", status.Error(codes.Canceled, "canceled")),
expected: true,
},
{
name: "joined gRPC canceled error",
err: errors.Join(fmt.Errorf("oh no"), status.Error(codes.Canceled, "canceled")),
expected: true,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
assert.Equalf(t, tc.expected, IsDownstreamError(tc.err), "IsDownstreamError(%v)", tc.err)
})
}
}

var _ net.Error = &fakeNetworkError{}

type fakeNetworkError struct {
timeout bool
temporary bool
}

func newFakeNetworkError(timeout, temporary bool) *fakeNetworkError {
return &fakeNetworkError{
timeout: timeout,
temporary: temporary,
}
}

func (d *fakeNetworkError) Error() string {
return "dummy timeout error"
}

func (d *fakeNetworkError) Timeout() bool {
return d.timeout
}

func (d *fakeNetworkError) Temporary() bool {
return d.temporary
var s backend.ErrorSource
require.False(t, s.IsValid())
require.Equal(t, "plugin", s.String())
require.True(t, backend.ErrorSourceDownstream.IsValid())
require.Equal(t, "downstream", backend.ErrorSourceDownstream.String())
require.True(t, backend.ErrorSourcePlugin.IsValid())
require.Equal(t, "plugin", backend.ErrorSourcePlugin.String())
}
24 changes: 24 additions & 0 deletions backend/httpclient/error_source_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package httpclient

import (
"net/http"

"github.com/grafana/grafana-plugin-sdk-go/backend/status"
)

// ErrorSourceMiddlewareName is the middleware name used by ErrorSourceMiddleware.
const ErrorSourceMiddlewareName = "ErrorSource"

// ErrorSourceMiddleware inspect the response error and wraps it in a [status.DownstreamError] if [status.IsDownstreamHTTPError] returns true.
func ErrorSourceMiddleware() Middleware {
return NamedMiddlewareFunc(ErrorSourceMiddlewareName, func(_ Options, next http.RoundTripper) http.RoundTripper {
return RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
res, err := next.RoundTrip(req)
if err != nil && status.IsDownstreamHTTPError(err) {
return res, status.DownstreamError(err)
}

return res, err
})
})
}
1 change: 1 addition & 0 deletions backend/httpclient/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ func DefaultMiddlewares() []Middleware {
BasicAuthenticationMiddleware(),
CustomHeadersMiddleware(),
ContextualMiddleware(),
ErrorSourceMiddleware(),
}
}

Expand Down
Loading

0 comments on commit bc0110f

Please sign in to comment.