Skip to content
Open
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
43 changes: 35 additions & 8 deletions pkg/lambda/grpc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/lambda"
"github.com/aws/aws-sdk-go-v2/service/lambda/types"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
Expand All @@ -24,6 +25,8 @@ type lambdaTransport struct {
functionName string
}

const lambdaInvokeRequestIDMetadataKey = "x-amzn-requestid"

func (l *lambdaTransport) RoundTrip(ctx context.Context, req *Request) (*Response, error) {
payload, frameOnly, err := req.marshalPayload()
if err != nil {
Expand Down Expand Up @@ -82,7 +85,17 @@ func (l *lambdaTransport) RoundTrip(ctx context.Context, req *Request) (*Respons
return nil, fmt.Errorf("lambda_transport: failed to unmarshal response: %w", err)
}

return resp, err
if requestID, ok := awsmiddleware.GetRequestIDMetadata(invokeResp.ResultMetadata); ok && requestID != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the invoke request ID is only surfaced on the success path. On the FunctionError path above, classifyLambdaFailure populates LambdaInvokeFailure.RequestID purely from parsing the tail log's REPORT RequestId: line, so it is empty whenever the REPORT line falls outside the 4KB tail window — exactly the OOM/timeout cases where the ID is most useful for CloudWatch lookup. Consider passing awsmiddleware.GetRequestIDMetadata(invokeResp.ResultMetadata) into classifyLambdaFailure as a fallback when report.RequestID is empty.

headers := resp.Headers()
headers.Set(lambdaInvokeRequestIDMetadataKey, requestID)
respHeaders, err := MarshalMetadata(headers)
if err != nil {
return nil, fmt.Errorf("lambda_transport: failed to encode response metadata: %w", err)
}
resp.msg.SetHeaders(respHeaders)
}

return resp, nil
}

// NewLambdaClientTransport returns a new client transport that invokes a lambda function.
Expand Down Expand Up @@ -136,6 +149,8 @@ func (c *clientConn) Invoke(ctx context.Context, method string, args any, reply
return err
}

populateResponseMetadata(tresp, opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: populateResponseMetadata runs after the tresp.Status() check, so when the response carries a missing or undecodable status (Status() returns codes.Internal at line 147-150) the caller's grpc.Header/grpc.Trailer targets are left untouched. grpc-go surfaces headers independently of status decoding. Moving this call to immediately after RoundTrip succeeds would make metadata available on every response that reached the client.


if st.Code() != codes.OK {
return st.Err()
}
Expand All @@ -145,21 +160,33 @@ func (c *clientConn) Invoke(ctx context.Context, method string, args any, reply
return err
}

// TODO(morgabra): call opts here, some are probably important (e.g. PerRPCCredsCallOption, etc)
return nil
}

func populateResponseMetadata(resp *Response, opts []grpc.CallOption) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this refactor deleted the // TODO(morgabra): call opts here, some are probably important (e.g. PerRPCCredsCallOption, etc) marker, but only HeaderCallOption and TrailerCallOption are handled — PerRPCCredsCallOption, MaxRecvMsgSizeCallOption, CallContentSubtype, etc. are still silently ignored. Since this is an exported grpc.ClientConnInterface, downstream callers get no signal that those options are no-ops. Please keep an equivalent TODO (or a doc comment on populateResponseMetadata noting the unsupported options) so the gap stays documented.

var headers metadata.MD
var trailers metadata.MD

for _, opt := range opts {
switch o := opt.(type) {
case grpc.HeaderCallOption:
for k, v := range tresp.Headers() {
o.HeaderAddr.Append(k, v...)
if o.HeaderAddr == nil {
continue
}
if headers == nil {
headers = resp.Headers()
}
*o.HeaderAddr = headers
case grpc.TrailerCallOption:
for k, v := range tresp.Trailers() {
o.TrailerAddr.Append(k, v...)
if o.TrailerAddr == nil {
continue
}
if trailers == nil {
trailers = resp.Trailers()
}
*o.TrailerAddr = trailers
}
}

return nil
}

func (c *clientConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
Expand Down
112 changes: 112 additions & 0 deletions pkg/lambda/grpc/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package grpc

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/lambda"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/structpb"

pbtransport "github.com/conductorone/baton-sdk/pb/c1/transport/v1"
)

type staticClientTransport struct {
response *Response
err error
}

func (t *staticClientTransport) RoundTrip(context.Context, *Request) (*Response, error) {
return t.response, t.err
}

func testResponse(t *testing.T, code codes.Code, headers, trailers metadata.MD) *Response {
t.Helper()

response, err := anypb.New(&structpb.Struct{})
require.NoError(t, err)
responseStatus, err := anypb.New(status.New(code, "response status").Proto())
require.NoError(t, err)
responseHeaders, err := MarshalMetadata(headers)
require.NoError(t, err)
responseTrailers, err := MarshalMetadata(trailers)
require.NoError(t, err)

return &Response{
msg: pbtransport.Response_builder{
Resp: response,
Status: responseStatus,
Headers: responseHeaders,
Trailers: responseTrailers,
}.Build(),
}
}

func TestLambdaClientConnPropagatesInvokeRequestIDAndResponseMetadata(t *testing.T) {
t.Parallel()

transportResponse := testResponse(t, codes.OK, metadata.Pairs("x-service-header", "server-value"), metadata.Pairs("x-service-trailer", "trailer-value"))
payload, err := json.Marshal(transportResponse)
require.NoError(t, err)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Amzn-Requestid", "invoke-request-id")
_, _ = w.Write(payload)
}))
defer server.Close()

lambdaClient := lambda.NewFromConfig(aws.Config{
Region: "us-east-1",
BaseEndpoint: aws.String(server.URL),
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
})
transport, err := NewLambdaClientTransport(context.Background(), lambdaClient, "test-function")
require.NoError(t, err)

var headers metadata.MD
var trailers metadata.MD
err = NewClientConn(transport).Invoke(
context.Background(),
"/test.Service/Method",
&structpb.Struct{},
&structpb.Struct{},
grpc.Header(&headers),
grpc.Trailer(&trailers),
)
require.NoError(t, err)
require.Equal(t, []string{"invoke-request-id"}, headers.Get(lambdaInvokeRequestIDMetadataKey))
require.Equal(t, []string{"server-value"}, headers.Get("x-service-header"))
require.Equal(t, []string{"trailer-value"}, trailers.Get("x-service-trailer"))
}

func TestClientConnReturnsResponseMetadataWithStatusError(t *testing.T) {
t.Parallel()

transport := &staticClientTransport{
response: testResponse(t, codes.PermissionDenied, metadata.Pairs("x-service-header", "server-value"), metadata.Pairs("x-service-trailer", "trailer-value")),
Comment on lines +92 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this test does not exercise the case the new UnmarshalMetadata nil guard exists for. testResponse always runs headers/trailers through MarshalMetadata, which returns a non-nil *structpb.Struct even for nil input, so msg.GetHeaders() is never nil here. The real server error path (ErrorResponse in util.go builds Headers: nil, Trailers: nil, used by every handler error and the panic recovery in pkg/cli/lambda_server__added.go) is what would have panicked in populateResponseMetadata before the guard was added. Consider a case with a pbtransport.Response built with nil Headers/Trailers (or an ErrorResponse(...) value) asserting empty, non-panicking metadata.

}
var headers metadata.MD
var trailers metadata.MD

err := NewClientConn(transport).Invoke(
context.Background(),
"/test.Service/Method",
&structpb.Struct{},
&structpb.Struct{},
grpc.Header(&headers),
grpc.Trailer(&trailers),
)
require.Equal(t, codes.PermissionDenied, status.Code(err))
require.Equal(t, []string{"server-value"}, headers.Get("x-service-header"))
require.Equal(t, []string{"trailer-value"}, trailers.Get("x-service-trailer"))
}
3 changes: 3 additions & 0 deletions pkg/lambda/grpc/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ func MarshalMetadata(md metadata.MD) (*structpb.Struct, error) {
// Only keys with []string values are converted.
// Empty string values are ignored.
func UnmarshalMetadata(s *structpb.Struct) metadata.MD {
if s == nil {
return metadata.MD{}
}
md := make(metadata.MD, len(s.Fields))
for k, v := range s.Fields {
lv := v.GetListValue()
Expand Down
Loading