Typed nil prevents correct nil checks in interceptors
Describe the bug connect-go internally uses typed nil, which causes issues when performing a nil check in an interceptor. Since the returned value is a typed nil, a standard if value == nil check does not work as expected, potentially leading to incorrect behavior in interceptors.
To Reproduce
example.proto
syntax = "proto3";
package example.v1;
option go_package = "example/gen/example/v1;examplev1";
message PingRequest {
string message = 1;
}
message PingResponse {
string message = 1;
}
service ExampleService {
rpc Ping(PingRequest) returns (PingResponse) {}
}
example_test.go:
package bugreport
import (
"context"
"fmt"
"net"
"net/http"
"testing"
"connectrpc.com/connect"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
examplev1 "example/gen/example/v1"
"example/gen/example/v1/examplev1connect"
)
type ExampleServer struct{}
func (s *ExampleServer) Ping(
ctx context.Context,
req *connect.Request[examplev1.PingRequest],
) (*connect.Response[examplev1.PingResponse], error) {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error"))
}
func exampleInterceptor() connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
res, err := next(ctx, req)
if res != nil {
fmt.Println("res is not nil")
res.Header().Add("example", "example") // <- this operation is panic (res is nil)
}
return res, err
}
}
}
type serverAndClient struct {
server *http.Server
client examplev1connect.ExampleServiceClient
}
func setupServerAndClient(
t *testing.T,
) *serverAndClient {
t.Helper()
es := &ExampleServer{}
mux := http.NewServeMux()
mux.Handle(examplev1connect.NewExampleServiceHandler(es, connect.WithInterceptors(exampleInterceptor())))
li, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, port, err := net.SplitHostPort(li.Addr().String())
if err != nil {
t.Fatal(err)
}
srv := &http.Server{
// Use h2c so we can serve HTTP/2 without TLS.
Handler: h2c.NewHandler(mux, &http2.Server{}),
}
go srv.Serve(li)
client := examplev1connect.NewExampleServiceClient(
http.DefaultClient,
fmt.Sprintf("http://localhost:%s", port),
)
return &serverAndClient{
server: srv,
client: client,
}
}
func (r *serverAndClient) close(t *testing.T) {
t.Helper()
err := r.server.Close()
if err != nil {
t.Fatal(err)
}
}
func TestThatReproducesBug(t *testing.T) {
sc := setupServerAndClient(t)
t.Cleanup(func() {
sc.close(t)
})
c := sc.client
ctx := t.Context()
c.Ping(ctx, &connect.Request[examplev1.PingRequest]{Msg: &examplev1.PingRequest{}})
}
test result
res is not nil
2025/02/13 18:43:27 http: panic serving 127.0.0.1:62326: runtime error: invalid memory address or nil pointer dereference
goroutine 13 [running]:
net/http.(*conn).serve.func1()
/Users/replu/sdk/go1.24.0/src/net/http/server.go:1947 +0xb0
panic({0x101058020?, 0x1013f5400?})
/Users/replu/sdk/go1.24.0/src/runtime/panic.go:787 +0x124
connectrpc.com/connect.(*Response[...]).Header(...)
/Users/replu/dev/pkg/mod/connectrpc.com/[email protected]/connect.go:265
example.setupServerAndClient.exampleInterceptor.func1.1({0x1010f5150?, 0x140001b6000?}, {0x1010f7230?, 0x140001ba080?})
/Users/replu/dev/src/tmp/example_test.go:33 +0x80
connectrpc.com/connect.NewUnaryHandler[...].func2({0x1017a81e0, 0x1400018a080})
/Users/replu/dev/pkg/mod/connectrpc.com/[email protected]/handler.go:69 +0x80
connectrpc.com/connect.(*Handler).ServeHTTP(0x14000160150, {0x1010f4a88, 0x140001b8000}, 0x14000162280)
/Users/replu/dev/pkg/mod/connectrpc.com/[email protected]/handler.go:237 +0x7e8
example/gen/example/v1/examplev1connect.NewExampleServiceHandler.func1({0x1010f4a88, 0x140001b8000}, 0x14000162280)
/Users/replu/dev/src/tmp/gen/example/v1/examplev1connect/example.connect.go:100 +0x90
net/http.HandlerFunc.ServeHTTP(0x1400013a0c0?, {0x1010f4a88?, 0x140001b8000?}, 0x0?)
/Users/replu/sdk/go1.24.0/src/net/http/server.go:2294 +0x38
net/http.(*ServeMux).ServeHTTP(0x14000182150?, {0x1010f4a88, 0x140001b8000}, 0x14000162280)
/Users/replu/sdk/go1.24.0/src/net/http/server.go:2822 +0x1b4
golang.org/x/net/http2/h2c.h2cHandler.ServeHTTP({{0x1010f0c40?, 0x1400013a0c0?}, 0x140001601c0?}, {0x1010f4a88, 0x140001b8000}, 0x14000162280)
/Users/replu/dev/pkg/mod/golang.org/x/[email protected]/http2/h2c/h2c.go:125 +0x4a8
net/http.serverHandler.ServeHTTP({0x14000200870?}, {0x1010f4a88?, 0x140001b8000?}, 0x1?)
/Users/replu/sdk/go1.24.0/src/net/http/server.go:3301 +0xbc
net/http.(*conn).serve(0x14000172870, {0x1010f5118, 0x14000182120})
/Users/replu/sdk/go1.24.0/src/net/http/server.go:2102 +0x52c
created by net/http.(*Server).Serve in goroutine 5
/Users/replu/sdk/go1.24.0/src/net/http/server.go:3454 +0x3d8
PASS
ok example 0.541s
Environment (please complete the following information):
connect-goversion or commit:v1.18.1go version:go version go1.24.0 darwin/arm64- your complete
go.mod:
go.mod:
module example
go 1.24.0
require (
connectrpc.com/connect v1.18.1
golang.org/x/net v0.34.0
)
require (
buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.3-20241031151143-70f632351282.1 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.3-20241127180247-a33202765966.1 // indirect
buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250106231242-56271afbd6ce.1 // indirect
buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.3-20250106231242-56271afbd6ce.1 // indirect
buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.3-20241007202033-cf42259fcbfc.1 // indirect
buf.build/go/bufplugin v0.6.0 // indirect
buf.build/go/protoyaml v0.3.1 // indirect
buf.build/go/spdx v0.2.0 // indirect
cel.dev/expr v0.19.1 // indirect
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.5.2 // indirect
connectrpc.com/otelconnect v0.7.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.12.9 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/bufbuild/buf v1.50.0 // indirect
github.com/bufbuild/protocompile v0.14.1 // indirect
github.com/bufbuild/protoplugin v0.0.0-20250106231243-3a819552c9d9 // indirect
github.com/bufbuild/protovalidate-go v0.8.2 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe // indirect
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect
github.com/containerd/containerd v1.7.25 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v27.5.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v27.5.0+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/envoyproxy/go-control-plane v0.13.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fullstorydev/grpcurl v1.9.2 // indirect
github.com/go-chi/chi/v5 v5.2.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.22.1 // indirect
github.com/google/go-containerregistry v0.20.2 // indirect
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jdx/go-netrc v1.0.0 // indirect
github.com/jhump/protoreflect v1.16.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/mount v0.3.4 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/reexec v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/onsi/ginkgo/v2 v2.22.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runtime-spec v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/profile v1.7.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.48.2 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.4.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/tetratelabs/wazero v1.8.2 // indirect
github.com/vbatts/tar-split v0.11.6 // indirect
go.lsp.dev/jsonrpc2 v0.10.0 // indirect
go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
go.lsp.dev/protocol v0.12.0 // indirect
go.lsp.dev/uri v0.3.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 // indirect
google.golang.org/grpc v1.69.4 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
pluginrpc.com/pluginrpc v0.5.0 // indirect
)
tool (
connectrpc.com/connect/cmd/protoc-gen-connect-go
github.com/bufbuild/buf/cmd/buf
github.com/fullstorydev/grpcurl/cmd/grpcurl
google.golang.org/protobuf/cmd/protoc-gen-go
)
Additional context I have identified one occurrence of this problem in https://github.com/connectrpc/connect-go/blob/main/handler.go#L51-L57 If this were the only, I could send a PR. However, I have not investigated whether there are other occurrences in the codebase.
@replu, that is not the proper idiom. Code should examine if the error is nil, and if it is not assume the other value is. So in that case, instead of checking res != nil and then doing something with it, you need the following:
res, err := next(ctx, req)
-if res != nil {
+if err == nil {
res.Header().Add("example", "example")
}
This is fairly normal/idiomatic in Go APIs that return either a value or an error.
@jhump
I’m already familiar with and agree that checking err == nil is the idiomatic way in Go. However, my concern is about interceptors that do not care about the error but only need to modify the response when it is not nil.
For example, if an interceptor wants to add a header (e.g., request_id) only when res is not nil, it becomes problematic because res != nil does not work as expected due to typed nil.
What do you think about this use case?
If err is nil then res is guaranteed to be not-nil. Checking the nilness of err is the correct way to assert what you want, as @jhump suggested.
Hi @nicksnyder
If err is nil then res is guaranteed to be not-nil.
This would certainly be true in this regard. However, this is only a matter of etiquette.
However, there does not appear to be any explicit written comments about it. https://github.com/connectrpc/connect-go/blob/v1.18.1/interceptor.go#L21-L27 https://github.com/connectrpc/connect-go/blob/v1.18.1/connect.go#L288-L304
We melted time to investigate this issue. A comment about the relationship between err and response would reduce the number of people struggling with similar problems.
For reference: io#CopyN
Thank you.
Re-opening just to serve as a doc bug: we don't intend to change the current behavior, but we definitely want to be more clear in the docs and in Go doc comments about the correct usage pattern.