Skip to content

Commit

Permalink
Enrich errors with error source using grpc status package (#1163)
Browse files Browse the repository at this point in the history
* enrich errors with error source using grpc status package

* add tests

* fix lint issues

* move more logic to SDK

* update comment

* add more tests

* fix linter
  • Loading branch information
wbrowne authored Jan 8, 2025
1 parent bba8d97 commit bd0a1c5
Show file tree
Hide file tree
Showing 5 changed files with 337 additions and 1 deletion.
71 changes: 70 additions & 1 deletion backend/data_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
215 changes: 215 additions & 0 deletions backend/data_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions backend/error_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions experimental/status/status_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit bd0a1c5

Please sign in to comment.