Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error source HTTP client middleware #1106

Merged
merged 9 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/experimental/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
117 changes: 22 additions & 95 deletions backend/error_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,144 +2,71 @@ package backend

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

"github.com/grafana/grafana-plugin-sdk-go/experimental/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
}

// 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
}

type errorWithSourceImpl struct {
source ErrorSource
err error
return status.SourceFromHTTPStatus(statusCode)
}

// 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 {
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 status.IsDownstreamError(err)
}

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/experimental/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
})
})
}
Loading
Loading