Error metadata is sent as trailers instead of headers in unary RPCs
Describe the bug
When setting metadata on an error, the metadata is sent as a trailer even for Unary RPCs.
According to the documentation for Error.Meta, I expected the metadata to be sent as headers:
Metadata attached to errors returned by unary handlers is always sent as HTTP headers, regardless of the protocol.
To Reproduce
A reproduction repository is available here:
https://github.com/frozenbonito/connect-error-metadata
The main.go file starts a Connect server that returns an error with attached metadata, then uses a grpc-go client to call it and inspect the headers and trailers.
For comparison, it also includes a similar grpc-go server and performs the same inspection.
$ git clone https://github.com/frozenbonito/connect-error-metadata
$ cd connect-error-metadata/
$ go run main.go
Connect
rpc error: code = Unimplemented desc = unimplemented
header: map[content-type:[application/grpc] date:[Thu, 05 Jun 2025 01:19:53 GMT] grpc-accept-encoding:[gzip]]
trailer: map[custom-key:[value]]
-----------------
gRPC
rpc error: code = Unimplemented desc = unimplemented
header: map[content-type:[application/grpc] custom-key:[value]]
trailer: map[]
custom-key is the metadata added by the handler.
Environment (please complete the following information):
connect-goversion or commit:v1.18.1go version:go version go1.24.3 linux/amd64- your complete
go.mod:
module github.com/frozenbonito/connect-error-metadata
go 1.24.3
require (
connectrpc.com/connect v1.18.1
golang.org/x/net v0.35.0
google.golang.org/grpc v1.72.2
google.golang.org/protobuf v1.36.6
)
require (
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
)
Additional context
As demonstrated in the reproduction code, grpc-go sends metadata as headers even for error responses.
@frozenbonito, I apologize for the confusion. But the doc states that they are always sent as HTTP headers, not as RPC header metadata. So if you were using middleware for "net/http", you will see them in the Header field of an *http.Response, not in the Trailer field. They are then interpreted as trailer metadata -- not just by a connect-go client but also by a grpc-go client -- because the actual error response uses what's called in the spec a "trailers only response" (which ironically enough, despite the name, is really headers-only at the HTTP transport layer, but they are all interpreted as RPC trailer metadata).
The comparison to grpc-go is definitely not apples-to-apples because they supply separate grpc.SetHeader and grpc.SetTrailer functions, to set them independently. But in connect-go, there is only a single way to set this metadata, and it should be interpreted by clients as trailer metadata. The reason goes back to the point above, about how "trailers only" responses for errors look.
@jhump Thank you for your comment. I now understand that this behavior is intentional and not a bug.
With that in mind, I’d like to ask a follow-up question: Why does connect-go not support non–trailers-only error responses like grpc-go does?
In my use case, I’m considering migrating a gRPC server implemented with grpc-go to one implemented with connect-go. This server application sets certain header metadata in an interceptor regardless of whether the RPC status is OK or not. The client, however, is implemented in a language (C++) that is not yet supported by Connect, so it must continue to use gRPC instead of Connect. In this situation, would it be impossible to migrate from grpc-go to connect-go while maintaining compatibility?