Today we’re releasing connect-go v2, a new major version of the Go implementation of Connect.

Before you panic, this is not a v2 of the Connect protocol. The breaking changes are in the Go API, so v2 clients and v1 servers continue to talk to each other without issue.

Also, v1 is not going anywhere: it will keep receiving fixes and security patches indefinitely. And because v2 uses the module path connectrpc.com/connect/v2, both versions can coexist in the same program and you can migrate one service at a time, or not at all.

So let’s cover what v2 changes and why we decided to introduce some breaking changes:

  • Killed our overly complicated use of generics
  • Detached the core library from net/http
  • Fixed our interceptor interface
  • Removed a risky behavior with error messages

No more generic wrappers

Our use of generics turned out to be more pain than it was worth. When we designed v1, we wrapped unary requests and responses in one of two generic types, connect.Request[T] or connect.Response[T], where T is the message type. This made headers, trailers, and the rest of a call’s metadata available without reaching into context.Context. We thought this benefit was worth the complexity that comes with generic types.

We were wrong. After four years of production use, we’ve found that most calls don’t need those wrappers.

connect-go v1 required you to call connect.NewRequest(...) before calling RPC methods and to dig into .Msg to access the actual message. It seems relatively small, but this put connect-go at odds with the rest of the Go RPC world, where func(context.Context, *Request) (*Response, error) is the shape people expect. This made porting to ConnectRPC from gRPC-Go needlessly complex.

We had an extended ‘beta test’ of this change. In v1.19.0, we introduced a simple flag when generating ConnectRPC code. The feedback has been positive, and many developers who try it prefer the simpler signatures.

So in v2, unary RPCs have the same familiar shape as gRPC-Go:

-Say(context.Context, *connect.Request[SayRequest]) (*connect.Response[SayResponse], error)
+Say(context.Context, *SayRequest) (*SayResponse, error)

If you’ve been holding off on moving a gRPC-Go codebase to Connect, v2 makes that move easier.

Removing generics also reduces binary size: the compiler no longer stamps out a client and method set for every RPC. Moving the Buf CLI to v2 shrank its stripped binary by around 10%.

Metadata is still available when you need it through a *connect.CallInfo retrieved from the context. See Generated code in the v2 guide for the full details.

Pluggable transports

We built v1 around HTTP semantics, so users provide a normal http.Client instance and services act as http.Handler implementations. As you can imagine, this works well for HTTP but it is actively preventing connect-go from being used with other transports. If you wanted to use Connect over WebSockets or stdin/stdout you would have to translate to and from HTTP semantics.

In v2, HTTP support lives in connecthttp, and other transports can use the same generated clients and handlers without needing to work with net/http.

We also added connectinprocess, an in-memory transport. You can use it to test Connect endpoints without complicated buffering tricks or starting an HTTP server. See Transports in the v2 guide for the full details.

One interceptor model

v1’s Interceptor interface was limiting and awkward in some important cases. Unary handler interceptors would run after decompression and decoding. An authentication interceptor can reject a request, but the server has already done the work of decoding and decompressing. This lets unauthenticated callers consume server resources before their credentials are checked. To reject requests earlier, authentication has to run in HTTP middleware around the RPC handler.

There are other issues as well:

  • Unary interceptor timings exclude unmarshal time, while streaming interceptor timings include it. This skews metrics.
  • There’s no way for the interceptor to read the message sizes on the wire (#665).
  • Streaming interceptors were harder to implement than unary interceptors, so we saw many users apply their interceptor logic only to unary calls.

v2 replaces the single Interceptor interface with two function types, ClientInterceptor and ServerInterceptor.

-type UnaryFunc func(context.Context, AnyRequest) (AnyResponse, error)
-type StreamingClientFunc func(context.Context, Spec) StreamingClientConn
-type StreamingHandlerFunc func(context.Context, StreamingHandlerConn) error
-
-type Interceptor interface {
-	WrapUnary(UnaryFunc) UnaryFunc
-	WrapStreamingClient(StreamingClientFunc) StreamingClientFunc
-	WrapStreamingHandler(StreamingHandlerFunc) StreamingHandlerFunc
-}
+type ClientFunc func(ctx context.Context, spec Spec) (ClientStream, error)
+type ServerFunc func(ctx context.Context, spec Spec, stream ServerStream) error
+
+type ClientInterceptor func(next ClientFunc) ClientFunc
+type ServerInterceptor func(next ServerFunc) ServerFunc

In v2, both interceptor types cover unary and streaming calls, and server interceptors can reject requests before decompression or decoding.

See Interceptors in the v2 guide for more information on how to write a v2 interceptor.

Safer error handling

In v1, returning an ordinary Go error from a handler sent its message to the client, potentially exposing sensitive details from a database error. In v2, only locally created *connect.Error values have their messages sent to clients.

See Errors in the v2 guide for the full details.

Migrating

Migration is optional, and v1 and v2 can coexist in one module. If you do migrate, there’s a tool that handles most of the mechanical work for you:

go install connectrpc.com/connect/v2/cmd/connect-go-v2-migrate@latest

The migration guide walks through installing the v2 generator and running the migration tool.

Note that some changes still need a human. Custom interceptors have to be rewritten to the new function types, otelconnect.NewInterceptor() has to be split into its server and client forms, and vanguard’s v2 API changed substantially. The tool prints a warning for each instance where it can’t safely migrate code over, so those warnings can act as a to-do list for the migration.

If you have feedback or need help migrating, email feedback@buf.build or join us in Slack.