diff --git a/backend/data_adapter.go b/backend/data_adapter.go index ec20ee908..919e51ac1 100644 --- a/backend/data_adapter.go +++ b/backend/data_adapter.go @@ -4,9 +4,18 @@ import ( "context" "errors" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/grafana/grafana-plugin-sdk-go/experimental/status" "github.com/grafana/grafana-plugin-sdk-go/genproto/pluginv2" ) +const ( + errorSourceMetadataKey = "errorSource" +) + // dataSDKAdapter adapter between low level plugin protocol and SDK interfaces. type dataSDKAdapter struct { queryDataHandler QueryDataHandler @@ -22,7 +31,7 @@ func (a *dataSDKAdapter) QueryData(ctx context.Context, req *pluginv2.QueryDataR parsedReq := FromProto().QueryDataRequest(req) resp, err := a.queryDataHandler.QueryData(ctx, parsedReq) if err != nil { - return nil, err + return nil, enrichWithErrorSourceInfo(err) } if resp == nil { @@ -31,3 +40,63 @@ func (a *dataSDKAdapter) QueryData(ctx context.Context, req *pluginv2.QueryDataR return ToProto().QueryDataResponse(resp) } + +// enrichWithErrorSourceInfo returns a gRPC status error with error source info as metadata. +func enrichWithErrorSourceInfo(err error) error { + var errorSource status.Source + if IsDownstreamError(err) { + errorSource = status.SourceDownstream + } else if IsPluginError(err) { + errorSource = status.SourcePlugin + } + + // Unless the error is explicitly marked as a plugin or downstream error, we don't enrich it. + if errorSource == "" { + return err + } + + status := grpcstatus.New(codes.Unknown, err.Error()) + status, innerErr := status.WithDetails(&errdetails.ErrorInfo{ + Metadata: map[string]string{ + errorSourceMetadataKey: errorSource.String(), + }, + }) + if innerErr != nil { + return err + } + + return status.Err() +} + +// HandleGrpcStatusError handles gRPC status errors by extracting the error source from the error details and injecting +// the error source into context. +func ErrorSourceFromGrpcStatusError(ctx context.Context, err error) (status.Source, bool) { + st := grpcstatus.Convert(err) + if st == nil { + return status.DefaultSource, false + } + for _, detail := range st.Details() { + if errorInfo, ok := detail.(*errdetails.ErrorInfo); ok { + errorSource, exists := errorInfo.Metadata[errorSourceMetadataKey] + if !exists { + break + } + + switch errorSource { + case string(ErrorSourceDownstream): + innerErr := WithErrorSource(ctx, ErrorSourceDownstream) + if innerErr != nil { + Logger.Error("Could not set downstream error source", "error", innerErr) + } + return status.SourceDownstream, true + case string(ErrorSourcePlugin): + errorSourceErr := WithErrorSource(ctx, ErrorSourcePlugin) + if errorSourceErr != nil { + Logger.Error("Could not set plugin error source", "error", errorSourceErr) + } + return status.SourcePlugin, true + } + } + } + return status.DefaultSource, false +} diff --git a/backend/data_adapter_test.go b/backend/data_adapter_test.go index e4addcfa1..b708907cb 100644 --- a/backend/data_adapter_test.go +++ b/backend/data_adapter_test.go @@ -3,14 +3,20 @@ package backend import ( "bytes" "context" + "errors" "fmt" "io" "net/http" "net/http/httptest" "testing" + "github.com/grafana/grafana-plugin-sdk-go/experimental/status" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + grpcstatus "google.golang.org/grpc/status" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/genproto/pluginv2" @@ -147,6 +153,215 @@ func TestQueryData(t *testing.T) { }) require.NoError(t, err) }) + + t.Run("Error source error from QueryData handler will be enriched with grpc status", func(t *testing.T) { + t.Run("When error is a downstream error", func(t *testing.T) { + adapter := newDataSDKAdapter(QueryDataHandlerFunc( + func(_ context.Context, _ *QueryDataRequest) (*QueryDataResponse, error) { + return nil, DownstreamError(errors.New("oh no")) + }, + )) + + _, err := adapter.QueryData(context.Background(), &pluginv2.QueryDataRequest{ + PluginContext: &pluginv2.PluginContext{}, + }) + require.Error(t, err) + + st := grpcstatus.Convert(err) + require.NotNil(t, st) + require.NotEmpty(t, st.Details()) + for _, detail := range st.Details() { + errorInfo, ok := detail.(*errdetails.ErrorInfo) + require.True(t, ok) + require.NotNil(t, errorInfo) + errorSource, exists := errorInfo.Metadata["errorSource"] + require.True(t, exists) + require.Equal(t, ErrorSourceDownstream.String(), errorSource) + } + }) + + t.Run("When error is a plugin error", func(t *testing.T) { + adapter := newDataSDKAdapter(QueryDataHandlerFunc( + func(_ context.Context, _ *QueryDataRequest) (*QueryDataResponse, error) { + return nil, PluginError(errors.New("oh no")) + }, + )) + + _, err := adapter.QueryData(context.Background(), &pluginv2.QueryDataRequest{ + PluginContext: &pluginv2.PluginContext{}, + }) + require.Error(t, err) + + st := grpcstatus.Convert(err) + require.NotNil(t, st) + require.NotEmpty(t, st.Details()) + for _, detail := range st.Details() { + errorInfo, ok := detail.(*errdetails.ErrorInfo) + require.True(t, ok) + require.NotNil(t, errorInfo) + errorSource, exists := errorInfo.Metadata["errorSource"] + require.True(t, exists) + require.Equal(t, ErrorSourcePlugin.String(), errorSource) + } + }) + + t.Run("When error is neither a downstream or plugin error", func(t *testing.T) { + adapter := newDataSDKAdapter(QueryDataHandlerFunc( + func(_ context.Context, _ *QueryDataRequest) (*QueryDataResponse, error) { + return nil, errors.New("oh no") + }, + )) + + _, err := adapter.QueryData(context.Background(), &pluginv2.QueryDataRequest{ + PluginContext: &pluginv2.PluginContext{}, + }) + require.Error(t, err) + + st := grpcstatus.Convert(err) + require.NotNil(t, st) + require.Empty(t, st.Details()) + }) + }) +} + +func TestErrorSourceFromGrpcStatusError(t *testing.T) { + type args struct { + ctx func() context.Context + err func() error + } + type expected struct { + src status.Source + found bool + } + tests := []struct { + name string + args args + expected expected + }{ + { + name: "When error is nil", + args: args{ + ctx: context.Background, + err: func() error { return nil }, + }, + expected: expected{ + src: status.DefaultSource, + found: false, + }, + }, + { + name: "When error is not a grpc status error", + args: args{ + ctx: context.Background, + err: func() error { + return errors.New("oh no") + }, + }, + expected: expected{ + src: status.DefaultSource, + found: false, + }, + }, + { + name: "When error is a grpc status error without error details", + args: args{ + ctx: context.Background, + err: func() error { + return grpcstatus.Error(codes.Unknown, "oh no") + }, + }, + expected: expected{ + src: status.DefaultSource, + found: false, + }, + }, + { + name: "When error is a grpc status error with error details", + args: args{ + ctx: context.Background, + err: func() error { + st := grpcstatus.New(codes.Unknown, "oh no") + st, _ = st.WithDetails(&errdetails.ErrorInfo{ + Metadata: map[string]string{ + errorSourceMetadataKey: status.SourcePlugin.String(), + }, + }) + return st.Err() + }, + }, + expected: expected{ + src: status.SourcePlugin, + found: true, + }, + }, + { + name: "When error is a grpc status error with error details, but context already has a source", + args: args{ + ctx: func() context.Context { + ctx := status.InitSource(context.Background()) + err := status.WithSource(ctx, status.SourceDownstream) + require.NoError(t, err) + return ctx + }, + err: func() error { + st := grpcstatus.New(codes.Unknown, "oh no") + st, _ = st.WithDetails(&errdetails.ErrorInfo{ + Metadata: map[string]string{ + errorSourceMetadataKey: status.SourcePlugin.String(), + }, + }) + return st.Err() + }, + }, + expected: expected{ + src: status.SourcePlugin, + found: true, + }, + }, + { + name: "When error is a grpc status error with error details but no error source", + args: args{ + ctx: context.Background, + err: func() error { + st := grpcstatus.New(codes.Unknown, "oh no") + st, _ = st.WithDetails(&errdetails.ErrorInfo{ + Metadata: map[string]string{}, + }) + return st.Err() + }, + }, + expected: expected{ + src: status.DefaultSource, + found: false, + }, + }, + { + name: "When error is a grpc status error with error details but error source is not a valid source", + args: args{ + ctx: context.Background, + err: func() error { + st := grpcstatus.New(codes.Unknown, "oh no") + st, _ = st.WithDetails(&errdetails.ErrorInfo{ + Metadata: map[string]string{ + errorSourceMetadataKey: "invalid", + }, + }) + return st.Err() + }, + }, + expected: expected{ + src: status.DefaultSource, + found: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src, ok := ErrorSourceFromGrpcStatusError(tt.args.ctx(), tt.args.err()) + assert.Equal(t, tt.expected.src, src) + assert.Equal(t, tt.expected.found, ok) + }) + } } var finalRoundTripper = httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { diff --git a/backend/error_source.go b/backend/error_source.go index 4a1cef45c..97e30362c 100644 --- a/backend/error_source.go +++ b/backend/error_source.go @@ -32,6 +32,10 @@ func ErrorSourceFromHTTPStatus(statusCode int) ErrorSource { return status.SourceFromHTTPStatus(statusCode) } +func IsPluginError(err error) bool { + return status.IsPluginError(err) +} + // 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 { diff --git a/experimental/status/status_source.go b/experimental/status/status_source.go index f4d443240..eda6b9858 100644 --- a/experimental/status/status_source.go +++ b/experimental/status/status_source.go @@ -116,6 +116,13 @@ func (e ErrorWithSource) Unwrap() error { return e.err } +func IsPluginError(err error) bool { + e := ErrorWithSource{ + source: SourcePlugin, + } + return errors.Is(err, e) +} + // 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 { diff --git a/experimental/status/status_source_test.go b/experimental/status/status_source_test.go index 0556c0d72..905b8f622 100644 --- a/experimental/status/status_source_test.go +++ b/experimental/status/status_source_test.go @@ -108,6 +108,47 @@ func TestIsDownstreamError(t *testing.T) { } } +func TestIsPluginError(t *testing.T) { + tcs := []struct { + name string + err error + expected bool + }{ + { + name: "nil", + err: nil, + expected: false, + }, + { + name: "plugin error", + err: backend.NewErrorWithSource(nil, backend.ErrorSourcePlugin), + expected: true, + }, + { + name: "downstream error", + err: backend.NewErrorWithSource(nil, backend.ErrorSourceDownstream), + expected: false, + }, + { + name: "other error", + err: fmt.Errorf("other error"), + expected: false, + }, + { + name: "network error", + err: newFakeNetworkError(true, true), + expected: false, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + wrappedErr := fmt.Errorf("error: %w", tc.err) + assert.Equalf(t, tc.expected, status.IsPluginError(tc.err), "IsPluginError(%v)", tc.err) + assert.Equalf(t, tc.expected, status.IsPluginError(wrappedErr), "wrapped IsPluginError(%v)", wrappedErr) + }) + } +} + func TestIsDownstreamHTTPError(t *testing.T) { tcs := []struct { name string