Skip to content

Commit

Permalink
Added a unary grpc client interceptor that will allow grpc clients to…
Browse files Browse the repository at this point in the history
… return errors that are compatbile with simplerr as well as the grpc status.FromError() and status.Code() methods
  • Loading branch information
Calvin Lobo committed Jan 5, 2024
1 parent 663e3b2 commit e9d8e91
Show file tree
Hide file tree
Showing 11 changed files with 579 additions and 95 deletions.
58 changes: 58 additions & 0 deletions ecosystem/grpc/client_interceptor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package simplegrpc

import (
"context"
"github.com/lobocv/simplerr"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

type attr int

const (
AttrGRPCMethod = attr(1)
AttrGRPCStatus = attr(2)
)

// ReturnSimpleErrors returns a unary client interceptor that converts errors returned by the client to simplerr compatible
// errors. The underlying grpc status and code can still be extracted using the same status.FromError() and status.Code() methods
func ReturnSimpleErrors(registry *Registry) grpc.UnaryClientInterceptor {

if registry == nil {
registry = defaultRegistry
}

return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {

// Call the gRPC method
err := invoker(ctx, method, req, reply, cc, opts...)
if err == nil {
return nil
}

grpcCode := codes.Unknown
simplerrCode := simplerr.CodeUnknown

Check failure on line 35 in ecosystem/grpc/client_interceptor.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to simplerrCode (ineffassign)

Check failure on line 35 in ecosystem/grpc/client_interceptor.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to simplerrCode (ineffassign)
msg := err.Error()

serr := simplerr.New(msg).
Attr(AttrGRPCMethod, method)

// Check if the error is a gRPC status error
// The GRPC framework seems to always return grpc errors on the client side, even if the server does not
// Therefore, this block should always run
if st, ok := status.FromError(err); ok {
_ = serr.Attr(AttrGRPCStatus, st)

grpcCode = st.Code()
simplerrCode, _ = registry.getGRPCCode(grpcCode)
_ = serr.Code(simplerrCode)
}

return &grpcError{
SimpleError: serr,
code: grpcCode,
}

}
}
96 changes: 96 additions & 0 deletions ecosystem/grpc/client_interceptor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package simplegrpc

import (
"context"
"fmt"
"github.com/lobocv/simplerr"
"github.com/lobocv/simplerr/ecosystem/grpc/internal/ping"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"net"
"testing"
)

type PingService struct {
err error
}

func (s *PingService) Ping(_ context.Context, _ *ping.PingRequest) (*ping.PingResponse, error) {
// Your implementation of the Ping method goes here
fmt.Println("Received Ping request")
return nil, s.err
}

func setupServerAndClient(port int) (*PingService, ping.PingServiceClient) {

server := grpc.NewServer()
service := &PingService{err: status.Error(codes.NotFound, "test error")}
ping.RegisterPingServiceServer(server, service)

// Create a listener on TCP port 50051
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
panic(fmt.Sprintf("Error creating listener: %v", err))
}

go func() {
if err := server.Serve(listener); err != nil {

Check failure on line 40 in ecosystem/grpc/client_interceptor_test.go

View workflow job for this annotation

GitHub Actions / lint

shadow: declaration of "err" shadows declaration at line 34 (govet)

Check failure on line 40 in ecosystem/grpc/client_interceptor_test.go

View workflow job for this annotation

GitHub Actions / lint

shadow: declaration of "err" shadows declaration at line 34 (govet)
panic(fmt.Sprintf("Error serving: %v", err))
}
}()

interceptor := ReturnSimpleErrors(nil)

conn, err := grpc.Dial(fmt.Sprintf(":%d", port),
grpc.WithUnaryInterceptor(interceptor),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
panic(err)
}
client := ping.NewPingServiceClient(conn)

return service, client
}

func TestClientInterceptor(t *testing.T) {

_, client := setupServerAndClient(50051)
_, err := client.Ping(context.Background(), &ping.PingRequest{})

require.True(t, simplerr.HasErrorCode(err, simplerr.CodeNotFound), "simplerror code can be detected")
require.Equal(t, codes.NotFound, status.Code(err), "grpc code can be detected with grpc status package")

st, ok := simplerr.GetAttribute(err, AttrGRPCStatus)
require.True(t, ok)
require.Equal(t, codes.NotFound, st.(*status.Status).Code(), "can get the grpc Status")

method, ok := simplerr.GetAttribute(err, AttrGRPCMethod)
require.True(t, ok)
require.Equal(t, "/ping.PingService/Ping", method, "can get the grpc method which errored")
}

// When a non grpc error is returned, the client still returns a grpc error with code Unknown
// Our interceptor should still be able to detect attributes on the error
func TestClientInterceptorNotGPRCError(t *testing.T) {

server, client := setupServerAndClient(50052)
server.err = fmt.Errorf("not a grpc error")

_, err := client.Ping(context.Background(), &ping.PingRequest{})

require.True(t, simplerr.HasErrorCode(err, simplerr.CodeUnknown), "simplerror code can be detected")
require.Equal(t, codes.Unknown, status.Code(err), "grpc code can be detected with grpc status package")

st, ok := simplerr.GetAttribute(err, AttrGRPCStatus)
require.True(t, ok)
require.Equal(t, codes.Unknown, st.(*status.Status).Code(), "can get the grpc Status")

method, ok := simplerr.GetAttribute(err, AttrGRPCMethod)
require.True(t, ok)
require.Equal(t, "/ping.PingService/Ping", method, "can get the grpc method which errored")

}
53 changes: 53 additions & 0 deletions ecosystem/grpc/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package simplegrpc

import (
"github.com/lobocv/simplerr"
"google.golang.org/grpc/codes"
)

var (
// defaultRegistry is a global registry used by default.
defaultRegistry = NewRegistry()
)

func GetDefaultRegistry() *Registry {
return defaultRegistry
}

// DefaultMapping returns the default mapping of SimpleError codes to gRPC error codes
func DefaultMapping() map[simplerr.Code]codes.Code {
m := map[simplerr.Code]codes.Code{
simplerr.CodeUnknown: codes.Unknown,
simplerr.CodeAlreadyExists: codes.AlreadyExists,
simplerr.CodeNotFound: codes.NotFound,
simplerr.CodeDeadlineExceeded: codes.DeadlineExceeded,
simplerr.CodeCanceled: codes.Canceled,
simplerr.CodeUnauthenticated: codes.Unauthenticated,
simplerr.CodePermissionDenied: codes.PermissionDenied,
simplerr.CodeNotImplemented: codes.Unimplemented,
simplerr.CodeInvalidArgument: codes.InvalidArgument,
simplerr.CodeResourceExhausted: codes.ResourceExhausted,
simplerr.CodeUnavailable: codes.Unavailable,
}

return m
}

// DefaultInverseMapping returns the default inverse mapping of gRPC error codes to SimpleError codes
func DefaultInverseMapping() map[codes.Code]simplerr.Code {
m := map[codes.Code]simplerr.Code{
codes.Unknown: simplerr.CodeUnknown,
codes.AlreadyExists: simplerr.CodeAlreadyExists,
codes.NotFound: simplerr.CodeNotFound,
codes.DeadlineExceeded: simplerr.CodeDeadlineExceeded,
codes.Canceled: simplerr.CodeCanceled,
codes.Unauthenticated: simplerr.CodeUnauthenticated,
codes.PermissionDenied: simplerr.CodePermissionDenied,
codes.Unimplemented: simplerr.CodeNotImplemented,
codes.InvalidArgument: simplerr.CodeInvalidArgument,
codes.ResourceExhausted: simplerr.CodeResourceExhausted,
codes.Unavailable: simplerr.CodeUnavailable,
}

return m
}
31 changes: 31 additions & 0 deletions ecosystem/grpc/grpc_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package simplegrpc

import (
"github.com/lobocv/simplerr"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// grpcError is a wrapper that exposes a SimpleError in a way that implements the gRPC status interface
// This is required because the grpc `status` library returns an error that does not implement unwrapping.
type grpcError struct {
*simplerr.SimpleError
code codes.Code
}

// Unwrap implement the interface required for error unwrapping
func (e *grpcError) Unwrap() error {
return e.SimpleError
}

// GRPCStatus implements an interface that the gRPC framework uses to return the gRPC status code
func (e *grpcError) GRPCStatus() *status.Status {
// If the status was attached as an attribute, return it
v, _ := simplerr.GetAttribute(e.SimpleError, AttrGRPCStatus)
st, ok := v.(*status.Status)
if ok {
return st
}

return status.New(e.code, e.Error())
}
Loading

0 comments on commit e9d8e91

Please sign in to comment.