diff --git a/pkg/lambda/grpc/client.go b/pkg/lambda/grpc/client.go index 81206188f..89ebeddba 100644 --- a/pkg/lambda/grpc/client.go +++ b/pkg/lambda/grpc/client.go @@ -18,8 +18,14 @@ import ( "google.golang.org/protobuf/proto" ) +// lambdaInvoker is the subset of *lambda.Client that the transport needs. It +// exists so RoundTrip can be unit-tested against a fake invoker. +type lambdaInvoker interface { + Invoke(ctx context.Context, params *lambda.InvokeInput, optFns ...func(*lambda.Options)) (*lambda.InvokeOutput, error) +} + type lambdaTransport struct { - lambdaClient *lambda.Client + lambdaClient lambdaInvoker functionName string } @@ -68,11 +74,16 @@ func (l *lambdaTransport) RoundTrip(ctx context.Context, req *Request) (*Respons } // If a third case is ever added to this, put the logic in its own function and add some test cases. + // The function was invoked but its own code returned an error (vs. an + // infra/invoke failure above). That's a caller/function-state fault, not a + // server fault: code it FailedPrecondition so it doesn't surface as a + // gRPC Unknown (which otel counts as a 5xx). if filteredLogs != "" { - return nil, fmt.Errorf("%s", filteredLogs) + return nil, status.Errorf(codes.FailedPrecondition, "%s", filteredLogs) } - return nil, fmt.Errorf( + return nil, status.Errorf( + codes.FailedPrecondition, "lambda_transport: function returned error: %s; status code: %d", *invokeResp.FunctionError, invokeResp.StatusCode, diff --git a/pkg/lambda/grpc/client_test.go b/pkg/lambda/grpc/client_test.go index c50ca5946..d025d307e 100644 --- a/pkg/lambda/grpc/client_test.go +++ b/pkg/lambda/grpc/client_test.go @@ -1,11 +1,66 @@ package grpc import ( + "context" + "encoding/base64" "testing" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +type fakeInvoker struct { + resp *lambda.InvokeOutput + err error +} + +func (f *fakeInvoker) Invoke(_ context.Context, _ *lambda.InvokeInput, _ ...func(*lambda.Options)) (*lambda.InvokeOutput, error) { + return f.resp, f.err +} + +// A function that runs but returns an error (FunctionError set) is a +// caller/function-state fault, not a server fault — RoundTrip must surface it +// as FailedPrecondition so it never shows up as a gRPC Unknown (counted 5xx). +func TestRoundTrip_FunctionErrorIsFailedPrecondition(t *testing.T) { + cases := []struct { + name string + payload string + logs string + }{ + { + name: "no meaningful logs", + payload: "{}", + logs: "START RequestId: abc\nEND RequestId: abc\n", + }, + { + name: "meaningful logs present", + payload: "{}", + logs: "Unhandled: connector failed to initialize\n", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tr := &lambdaTransport{ + functionName: "fn", + lambdaClient: &fakeInvoker{resp: &lambda.InvokeOutput{ + FunctionError: aws.String("Unhandled"), + StatusCode: 200, + Payload: []byte(c.payload), + LogResult: aws.String(base64.StdEncoding.EncodeToString([]byte(c.logs))), + }}, + } + + _, err := tr.RoundTrip(context.Background(), &Request{}) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err), "function-returned error must be FailedPrecondition, got: %v", err) + }) + } +} + func TestExtractMeaningfulLogLines(t *testing.T) { cases := []struct { name string