Skip to content

Conversation

@smaye81
Copy link
Member

@smaye81 smaye81 commented Jun 30, 2025

This adds the usage of CallInfo in context for issuing requests with the new simple API. This builds on top of the #851 which implements the simple flag for unary and server-streaming

In addition, it adds integration tests for the simple and generics API.

client.go Outdated
Comment on lines 146 to 147
maps.Copy(call.ResponseHeader(), resp.Header())
maps.Copy(call.ResponseTrailer(), resp.Trailer())
Copy link
Member

Choose a reason for hiding this comment

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

You might use an unexported newOutgoingContext that returns the concrete type *callInfo, instead of CallInfo. That way you can easily poke around at unexported fields. Ideally here, we wouldn't call call.ResponseHeader() since that will allocate another map. Instead, you could look directly at the unexported field and if it's nil, just set it to resp.Header(). In most cases, that skip an allocation and copy since most callers will never look at or care about these headers.

(Same for trailers.)

client.go Outdated

info.peer = conn.Peer()
info.spec = conn.Spec()
mergeHeaders(info.RequestHeader(), request.header)
Copy link
Member

@jhump jhump Jun 30, 2025

Choose a reason for hiding this comment

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

This is copying in the wrong direction. The caller may have setup headers already in the CallInfo, and you need to copy them into conn.RequestHeader(). In fact, you probably want to copy the info headers first. That way, if the caller setup headers in a context and also set headers up in a *Request (using "non-simple" code gen), the *Request passed to the RPC call would take precedence.

Although... looking at mergeHeaders it never overwrites entries, just appends. I guess that's the safest behavior (though could also be surprising if anyone expects things to override). Although there's still a question of what would you expect first in the list of headers; and I think the more intuitive answer is "headers from context first; then headers from explicit request".

context.go Outdated
// internalOnly implements CallInfo.
func (c *callInfo) internalOnly() {}

type callInfoContextKey struct{}
Copy link
Member

Choose a reason for hiding this comment

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

You need two different keys, one for outgoing and one for incoming. It is not safe for them to share the same key.

For example, an incoming call info (for a handler) could contain authentication credentials in request headers. Using the same context to make another RPC, if using the same context key, would see the call info in context and then propagate all of those headers along. That could be a security issue since it could be inappropriately sending the client's credentials to another server.

So "incoming" is for handlers because the call is incoming to the handler from a client. And "outgoing" is for clients, because they are making outgoing calls.

context.go Outdated
}

// CallInfoFromContext returns the CallInfo for the given context, if there is one.
func CallInfoFromContext(ctx context.Context) (CallInfo, bool) {
Copy link
Member

Choose a reason for hiding this comment

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

Need to distinguish between CallInfoFromOutgoingContext and CallInfoFromIncomingContext.

handler.go Outdated
Comment on lines 89 to 91
// Add response headers/trailers into the context callinfo also
mergeNonProtocolHeaders(call.ResponseHeader(), response.Header())
mergeNonProtocolHeaders(call.ResponseTrailer(), response.Trailer())
Copy link
Member

Choose a reason for hiding this comment

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

Like above, this is going the wrong direction. On the server, handler code can set the call info response headers and trailers, and we need to copy them into the conn so they correctly get sent to the client. Also, for both above and these calls, it's likely best to have a concrete value so you can examine the unexported fields and skip this step if the maps are nil/empty, instead of indirectly instantiating an unused map (since that happens inside call.ResponseHeader()).

handler.go Outdated
}
// Add the request header to the context, and store the response header
// and trailer to propagate back to the caller.
ctx, ci := NewOutgoingContext(ctx)
Copy link
Member

Choose a reason for hiding this comment

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

You'll want something more like so:

call := &callInfo{
    peer:          request.Peer(),
    spec:          request.Spec(),
    method:        request.HTTPMethod(),
    requestHeader: request.Header(),
}
ctx = newIncomingContext(ctx, call)

handler.go Outdated
Comment on lines 125 to 129
if ok {
response.setHeader(callInfo.ResponseHeader())
response.setTrailer(callInfo.ResponseTrailer())
}
return response, err
Copy link
Member

Choose a reason for hiding this comment

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

You shouldn't need to do this here. Everything related to call info is already being done above in the implementation function inside NewUnaryHandler.

handler.go Outdated
Comment on lines 179 to 184
ctx, ci := NewOutgoingContext(ctx)
callInfo, _ := ci.(*callInfo)
callInfo.peer = req.Peer()
callInfo.spec = req.Spec()
callInfo.method = req.HTTPMethod()
maps.Copy(callInfo.RequestHeader(), req.Header())
Copy link
Member

Choose a reason for hiding this comment

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

This is where you'd use an alternate implementation of CallInfo that just delegates to conn, since it should implement all of these methods (except HTTPMethod() which can be hard-coded to "POST" for streaming calls).

Something like so:

call := &streamCall{streamingConn: conn}
ctx = newIncomingContext(ctx, call)

Then you don't need to copy anything because the CallInfo methods directly delegate to the stream and thus directly return its maps for headers and trailers.

The streamCall could look something like:

type streamingConn interface {
    // subset of CallInfo that all streaming conns provide
    Peer() Peer
    Spec() Spec
    RequestHeader() http.Header
    ResponseHeader() http.Header
    ResponseTrailer() http.Trailer
}

type streamCall struct {
    streamingConn
}

func (c *streamCall) HTTPMethod() string {
    return http.MethodPost // all stream calls are POSTs.
}

@smaye81 smaye81 changed the title Simple flag Implement CallInfo usage in context and add integration tests Jul 1, 2025
@smaye81 smaye81 requested a review from jhump July 1, 2025 01:26
@smaye81 smaye81 marked this pull request as ready for review July 1, 2025 01:27
context.go Outdated
HTTPMethod() string
}

type StreamCallInfo interface {
Copy link
Member

Choose a reason for hiding this comment

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

We don't want to add an extra exported type to the API for this. And nothing even references this interface. So let's remove it and change these back to the way they were.

handler.go Outdated
Comment on lines 84 to 85
mergeNonProtocolHeaders(conn.ResponseHeader(), info.ResponseHeader())
mergeNonProtocolHeaders(conn.ResponseTrailer(), info.ResponseTrailer())
Copy link
Member

Choose a reason for hiding this comment

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

We don't want to allocate maps here if the headers and trailers were unset. So let's refer to the unexported fields of info instead. If they are nil/empty, this should do nothing.

In fact, we should probably do the same below for response, to similarly avoid unnecessary allocations.

Copy link
Member Author

Choose a reason for hiding this comment

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

I added the nil check to the info headers/trailers, but the response header/trailer functions lazily create the map, so I'm just checking for len == 0 on those.

handler.go Outdated
Comment on lines 83 to 87
// Add response headers/trailers into the context callinfo
mergeNonProtocolHeaders(conn.ResponseHeader(), info.ResponseHeader())
mergeNonProtocolHeaders(conn.ResponseTrailer(), info.ResponseTrailer())

// Add response headers/trailers into the conn
Copy link
Member

Choose a reason for hiding this comment

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

These comments could use a little clarification.

Suggested change
// Add response headers/trailers into the context callinfo
mergeNonProtocolHeaders(conn.ResponseHeader(), info.ResponseHeader())
mergeNonProtocolHeaders(conn.ResponseTrailer(), info.ResponseTrailer())
// Add response headers/trailers into the conn
// Add response headers/trailers from the context callinfo into the conn.
mergeNonProtocolHeaders(conn.ResponseHeader(), info.ResponseHeader())
mergeNonProtocolHeaders(conn.ResponseTrailer(), info.ResponseTrailer())
// Add response headers/trailers from the response into the conn.

client.go Outdated
Comment on lines 206 to 207
callInfo.responseHeader = conn.ResponseHeader()
callInfo.responseTrailer = conn.ResponseTrailer()
Copy link
Member

Choose a reason for hiding this comment

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

On the client conn.ResponseHeader() will block for actual headers to come back from the server. So this seems like it would deadlock -- we haven't half-closed the stream yet (that happens in the conn.CloseRequest() call below), and the server isn't expected to send response headers until that happens.

But just moving the call to after conn.CloseRequest() is called, below, still changes the semantics slightly. Previously, this method would send the request but then not block for the response headers until the first call to conn.ResponseHeader() or to conn.Receive(). But if we call conn.ResponseHeader() below, then we're moving that blocking into this function.

Maybe we need another implementation of callInfo -- like a clientCallInfo that doesn't have its own source of response headers and trailers. Instead it could embed the source and do something like this:

type responseSource interface {
    ResponseHeader() http.Header
    ResponseTrailer() http.Header
}

type clientCallInfo struct {
    // .... other fields to implement other methods ...
    responseSource
}

func (c *clientCallInfo) ResponseHeader() http.Header {
    if c.responseSource == nil {
        return nil
    }
    return c.responseSource.ResponseHeader()
}

func (c *clientCallInfo) ResponseTrailer() http.Header {
    if c.responseSource == nil {
        return nil
    }
    return c.responseSource.ResponseTrailer()
}

Then, above, when setting the other fields of callInfo, you can also set callInfo.responseSource = conn. For unary, you can set it to the *Response.

I think we'd need to add some docs to the CallInfo interface, too. Something like so to describe ResponseHeaders and ResponseTrailers:

On the client side, these methods return nil before the call is actually made. After the call is made, for streaming operations, this method will block for the server to actually return response headers.

You might also add something to the docs for CallInfo to clarify that CallInfo values are not thread-safe. (So if someone creates an outgoing info, they should expect data races if they pass that info to another goroutine and try to call methods on it concurrent with the RPC.)

client.go Outdated
Comment on lines 138 to 149
callInfo.peer = request.Peer()
callInfo.spec = request.Spec()
callInfo.method = request.HTTPMethod()
if callInfo.responseHeader == nil {
callInfo.responseHeader = resp.Header()
} else {
mergeHeaders(callInfo.ResponseHeader(), resp.Header())
}
if callInfo.responseTrailer == nil {
callInfo.responseTrailer = resp.Trailer()
} else {
mergeHeaders(callInfo.ResponseTrailer(), resp.Trailer())
Copy link
Member

Choose a reason for hiding this comment

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

It would be ideal if interceptors could examine the context for these. But, as this is, we won't set anything on the call info until after interceptors return. The callUnary function above runs all interceptors and then the handler.

We could remedy this by moving this logic into the initial definition of callUnary, around line 78 inside NewClient, right after the call to receiveUnaryResponse. You'll have to dig the call info out of the context again, but I think that should work. You probably also want to leave a comment here explaining why we're not doing that here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Moved this and then added tests to interceptor_ext_test.go to verify peer, spec, and method. We probably need more interceptor tests, but it seems like that might be a follow-up since the interceptor tests require a bit of setup? I'm thinking maybe we need:

  • verify call info for streaming
  • verify headers in context info for streaming and unary
  • verify a unary handler can separately set response headers and trailers

context.go Outdated
Comment on lines 193 to 233
type outgoingCallInfoContextKey struct{}
type incomingCallInfoContextKey struct{}

// responseSource indicates a type that manage response headers and trailers.
type responseSource interface {
ResponseHeader() http.Header
ResponseTrailer() http.Header
}

// responseWrapper wraps a Response object so that it can implement the responseSource interface.
type responseWrapper[Res any] struct {
response *Response[Res]
}

// SetResponseTrailer sets the response trailer within a simple handler implementation.
func SetResponseTrailer(ctx context.Context, trailer http.Header) {
responseTrailerAddress, ok := ctx.Value(responseTrailerAddressContextKey{}).(*http.Header)
func (w *responseWrapper[Res]) ResponseHeader() http.Header {
return w.response.Header()
}

func (w *responseWrapper[Res]) ResponseTrailer() http.Header {
return w.response.Trailer()
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Think I got the placement right on these.

@smaye81 smaye81 force-pushed the simple branch 3 times, most recently from 8b575bc to 6f221f0 Compare July 3, 2025 19:31
@smaye81 smaye81 force-pushed the simple branch 4 times, most recently from f85d9db to 6ce80e6 Compare July 21, 2025 15:16
Steve Ayers and others added 14 commits July 22, 2025 12:30
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Co-authored-by: Joshua Humphries <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Steve Ayers and others added 21 commits July 23, 2025 12:02
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Co-authored-by: Joshua Humphries <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
…xported NewClientContextAPI

Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>

Fix names

Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
@smaye81
Copy link
Member Author

smaye81 commented Jul 23, 2025

@jhump incorporated all your feedback in fa4e176. Lmk what you think.

@smaye81 smaye81 requested a review from jhump July 23, 2025 16:05
smaye81 and others added 3 commits July 23, 2025 14:32
@jhump jhump merged commit c9cf4bc into connectrpc:simple Jul 24, 2025
1 of 2 checks passed
smaye81 pushed a commit to smaye81/connect-go that referenced this pull request Aug 14, 2025
…trpc#856)

This adds the usage of CallInfo in context for issuing requests with the
new simple API. This builds on top of the
connectrpc#851 which implements the
simple flag for unary and server-streaming

In addition, it adds integration tests for the simple and generics API.
It also implements the simple approach for client and bidi streams.

---------

Signed-off-by: Steve Ayers <[email protected]>
Signed-off-by: Joshua Humphries <[email protected]>
Signed-off-by: John Chadwick <[email protected]>
Signed-off-by: Steve Ayers <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants