Skip to content

Latest commit

 

History

History
799 lines (619 loc) · 25.5 KB

File metadata and controls

799 lines (619 loc) · 25.5 KB

Connect v1 to v2 migration guide

This document will walk you through all you need to know to migrate from v1 to v2. connect-go v2 improves and simplifies some common APIs.

To get started we will first install the migration tool. This will help automate most of the mechanical translations. Then go through examples, and any decisions that might show up and require oversight.

Important

connect-go v1 remains supported. The v1 branch will receive fixes and security updates, so you can migrate at your own pace.

Running the migration tool

We provide the tool connect-go-v2-migrate which takes care of plugin updates and most code changes. First, install the tool:

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

Then run it from your module directory, or pass paths as arguments:

connect-go-v2-migrate

The tool is safe to run. By default it is a dry run that prints a diff for each file it would change, plus warnings for anything that needs manual work. Pass -w to write the changes to disk, and -json for a machine-readable report.

Most code changes depend on the re-generated v2 code, so the migration runs in two passes:

  1. Run connect-go-v2-migrate -w to update buf.gen.yaml and apply any code changes that don't depend on generated code.
  2. Re-generate your code with the v2 plugin (see Re-generate code).
  3. Run connect-go-v2-migrate -w again to finish the changes that bind to the v2 generated code. This will update client calls and handler signatures.
  4. Finally, run go mod tidy and then build and test addressing any manual operations warned from the report.

The project won't compile between steps 1 and 3. Work through the sequence, then fix any remaining warnings the tool printed. Services may be migrated one at a time to reduce the changes, both v1 and v2 libraries can be imported in the same module.

The sections below show each change. Changes are marked:

  • ✅ connect-go-v2-migrate handles this.
  • ⚠️ The tool prints a warning. Update the code by hand.

Update buf.gen.yaml

The v2 generator keeps the plugin name protoc-gen-connect-go. The required change depends on how buf.gen.yaml declares the plugin.

For remote plugins, the reference is pinned to the v2 release:

version: v2
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen
    opt: paths=source_relative
- - remote: buf.build/connectrpc/go:v1.18.1
+ - remote: buf.build/connectrpc/go:v2.0.0
    out: gen
    opt: paths=source_relative

✅ connect-go-v2-migrate handles this. It also replaces buf.build/connectrpc/gosimple, since v2 makes the simple API the default generator.

BSR generated SDKs move to a /v2 module, so their import paths change:

-import "buf.build/gen/go/acme/user/connectrpc/go/acme/user/v1/userv1connect"
+import "buf.build/gen/go/acme/user/connectrpc/go/v2/acme/user/v1/userv1connect"

✅ connect-go-v2-migrate handles this in the first pass and adds the /v2 module to the go get command it prints.

For local plugins (local: protoc-gen-connect-go), the buf.gen.yaml entry stays the same because the v1 and v2 plugins share the binary name. Reinstalling the binary from the v2 module switches generation to v2:

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

If the plugin runs through go.mod (local: [go, tool, protoc-gen-connect-go]), update the tool dependency instead:

go get -tool connectrpc.com/connect/v2/cmd/protoc-gen-connect-go
go mod tidy

⚠️ The tool can't install binaries or change go.mod, so it prints the command to run.

v2 generated code is always in the simple style, and the generator rejects the v1 simple option:

version: v2
plugins:
  - local: protoc-gen-connect-go
    out: gen
    opt:
      - paths=source_relative
-     - simple=true

✅ connect-go-v2-migrate handles this, for both the list form and the inline form (opt: paths=source_relative,simple=true).

Re-generate code

With buf.gen.yaml updated and the plugin installed, re-generate:

buf generate

The migration tool never edits generated code, so this step is yours. After re-generating, run connect-go-v2-migrate -w again to migrate the code that depends on the generated packages.

Update dependencies

The tool rewrites import paths in your code but does not edit go.mod. After the final tool run, resolve the new modules:

go mod tidy

The core module and grpchealth and grpcreflect move to /v2 module paths. validate and otelconnect keep their paths:

v1 v2
connectrpc.com/connect connectrpc.com/connect/v2
connectrpc.com/grpchealth connectrpc.com/grpchealth/v2
connectrpc.com/grpcreflect connectrpc.com/grpcreflect/v2
connectrpc.com/validate connectrpc.com/validate
connectrpc.com/otelconnect connectrpc.com/otelconnect

Until the ecosystem packages are tagged, use the main branch:

go get connectrpc.com/grpchealth/v2@main connectrpc.com/grpcreflect/v2@main \
  connectrpc.com/validate@main connectrpc.com/otelconnect@main

The tool prints this command for the packages you import. connectrpc.com/authn keeps working on v1, so it can stay as is. The API changes in each package are covered in Ecosystem packages.

v2 also splits the runtime into subpackages of the same module. The core package no longer depends on net/http:

Package Contents
connectrpc.com/connect/v2 Core types, imported by generated code
connectrpc.com/connect/v2/connecthttp net/http server and client bindings
connectrpc.com/connect/v2/connectproto Protobuf codecs
connectrpc.com/connect/v2/connectgzip Gzip compression
connectrpc.com/connect/v2/connectinprocess In-process transport

Update your application code

Requests and responses

v1 wrapped every message in connect.Request[T] or connect.Response[T]. v2 generated code passes protobuf messages directly:

// v1
func (s *pingServer) Ping(
	ctx context.Context,
	req *connect.Request[pingv1.PingRequest],
) (*connect.Response[pingv1.PingResponse], error) {
	return connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}), nil
}
// v2
func (s *pingServer) Ping(
	ctx context.Context,
	req *pingv1.PingRequest,
) (*pingv1.PingResponse, error) {
	return &pingv1.PingResponse{Text: req.Text}, nil
}

Client calls change the same way: no connect.NewRequest wrapper, and no .Msg field access on the response.

✅ connect-go-v2-migrate handles this.

Server construction

v1 generated a constructor returning a path and an http.Handler. In v2, register services on a *connect.Server and mount it with connecthttp:

// v1
mux := http.NewServeMux()
mux.Handle(pingv1connect.NewPingServiceHandler(
	&pingServer{},
	connect.WithInterceptors(validate.NewInterceptor()),
))
// v2
mux := http.NewServeMux()
server := connect.NewServer(validate.NewServerInterceptor())
pingv1connect.RegisterPingServiceHandler(server, &pingServer{})
connecthttp.Mount(mux, server)

Interceptors move from connect.WithInterceptors(...) to arguments of connect.NewServer. Other handler options move to connecthttp.Mount.

In v2, interceptors apply to every service on a server; there is no per-handler option. Consecutive mux.Handle(...) calls with identical options share one server. Handlers with different interceptors keep their v1 behavior by registering on separate servers mounted on the same mux.

✅ connect-go-v2-migrate handles the inline mux.Handle(...) shape, grouping consecutive handlers with identical options onto one server. ⚠️ Handlers with differing options get one server per group; the tool warns so you can review whether they should share one. Other shapes, like assigning the path and handler to variables first, are also warned and need a manual update.

Client construction

v1 clients took an HTTP client and a base URL. v2 clients take a *connect.Client, which wraps a transport:

// v1
client := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080")
// v2
client := pingv1connect.NewPingServiceClient(connect.NewClient(
	connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"),
))

Interceptors move to connect.NewClient. Other client options move to connecthttp.NewTransport.

✅ connect-go-v2-migrate handles this.

Headers and trailers

With the wrapper types gone, metadata moves to a *connect.CallInfo carried on the context. It exposes RequestHeader(), ResponseHeader(), and ResponseTrailer().

Clients attach the info to the context before the call and read response metadata from it after:

// v1
req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"})
req.Header().Set("X-Request-Id", requestID)
res, err := client.Ping(ctx, req)
// v2
ctx, info := connect.NewClientContext(ctx)
info.RequestHeader().Set("X-Request-Id", requestID)
res, err := client.Ping(ctx, &pingv1.PingRequest{Text: "hello"})

Handlers get the info from the handler's context:

// v1
func (s *pingServer) Ping(
	ctx context.Context,
	req *connect.Request[pingv1.PingRequest],
) (*connect.Response[pingv1.PingResponse], error) {
	token := req.Header().Get("Authorization")
	res := connect.NewResponse(&pingv1.PingResponse{})
	res.Header().Set("X-Server-Name", "ping-server")
	return res, nil
}
// v2
func (s *pingServer) Ping(
	ctx context.Context,
	req *pingv1.PingRequest,
) (*pingv1.PingResponse, error) {
	info, _ := connect.CallInfoForServerContext(ctx)
	token := info.RequestHeader().Get("Authorization")
	res := &pingv1.PingResponse{}
	info.ResponseHeader().Set("X-Server-Name", "ping-server")
	return res, nil
}

The v1 connect.CallInfoForHandlerContext(ctx) is renamed to connect.CallInfoForServerContext(ctx); both return (CallInfo, bool), so existing comma-ok call sites carry over unchanged.

✅ connect-go-v2-migrate moves metadata access to the CallInfo:

  • A handler's request-header read (req.Header()) and response header/trailer set (res.Header()/res.Trailer() on a connect.NewResponse holder) become info.* on a seeded info, _ := connect.CallInfoForServerContext(ctx).
  • A client's request header set on a connect.NewRequest and response header/trailer read (res.Header()/res.Trailer()) become connect.NewClientContext(ctx) plus info.*.
  • connect.CallInfoForHandlerContext renames to connect.CallInfoForServerContext.

⚠️ A function that both sets a client request header and reads a client response header, or that touches several response holders, is warned rather than seeding one shared context.

Errors

connect.NewError takes a message string instead of an error. In v2 a plain error returned from a handler is never serialized to the wire, so internal details can't leak by accident.

// v1
return nil, connect.NewError(connect.CodeInternal, err)
// v2
return nil, connect.NewError(connect.CodeInternal, err.Error()).WithCause(err)

v1 sent the error's full string to the client, so the tool rewrites to err.Error() to preserve what callers see today, and attaches err with WithCause so errors.Is and errors.As still match. When the argument is a function call no WithCause is added, since repeating the call would evaluate it twice. Assign it to a variable first if you need errors.Is or errors.As to match.

Common argument shapes collapse to simpler forms with the same wire message:

connect.NewError(code, errors.New("nope"))     // -> connect.NewError(code, "nope")
connect.NewError(code, fmt.Errorf("x %s", a))  // -> connect.Errorf(code, "x %s", a)
connect.NewError(code, nil)                    // -> connect.NewError(code, "")

To hide the underlying error from clients, attach it as a cause instead. The cause is visible to errors.Is and errors.As on the server but never sent to the client:

return nil, connect.NewError(connect.CodeInternal, "something went wrong").WithCause(err)

Error details keep the v1 shape with the constructor moved to connectproto. AddDetail becomes the cloning WithDetail builder, and ErrorDetail.Value() becomes connectproto.UnmarshalErrorDetail:

// v1
cErr := connect.NewError(connect.CodeInternal, err)
if detail, derr := connect.NewErrorDetail(info); derr == nil {
	cErr.AddDetail(detail)
}
// v2
cErr := connect.NewError(connect.CodeInternal, err.Error()).WithCause(err)
if detail, derr := connectproto.NewErrorDetail(info); derr == nil {
	cErr = cErr.WithDetail(detail)
}

The wire-error helpers became *connect.Error methods:

v1 v2
connect.NewWireError(code, err) connect.NewError(code, msg).WithRemote()
connect.IsWireError(err) cerr, ok := errors.AsType[*connect.Error](err) then cerr.IsRemote()

Error codes (connect.CodeNotFound, connect.CodeOf, and friends) are unchanged.

✅ connect-go-v2-migrate handles the NewError rewrite and retargets the NewErrorDetail/AddDetail guard to connectproto, preserving the v1 wire message. Switching to WithCause is a behavior change to make by hand, and ⚠️ the wire-error helpers and other ErrorDetail uses are warned for a manual update.

Options moved to connecthttp

HTTP-specific options moved from the core package to connecthttp. Most keep their name and signature and only change package:

// v1
connect.WithReadMaxBytes(1024)
connect.WithSendMaxBytes(2048)
connect.WithCompressMinBytes(512)
connect.WithRequireConnectProtocolHeader()
connect.WithSendGzip()
connect.WithHTTPGet()
connect.WithHTTPGetMaxURLSize(8192, true)
connect.WithProtoJSON()
connect.WithGRPC()
connect.WithGRPCWeb()
connect.WithSendCompression("gzip")
// v2
connecthttp.WithReadMaxBytes(1024)
connecthttp.WithSendMaxBytes(2048)
connecthttp.WithCompressMinBytes(512)
connecthttp.WithRequireConnectProtocolHeader()
connecthttp.WithSendGzip()
connecthttp.WithHTTPGet()
connecthttp.WithHTTPGetMaxURLSize(8192, true)
connecthttp.WithProtoJSON()
connecthttp.WithGRPC()
connecthttp.WithGRPCWeb()
connecthttp.WithSendCompression("gzip")

The ErrorWriter type, NewErrorWriter, and IsNotModifiedError move to connecthttp the same way, keeping their signatures.

✅ connect-go-v2-migrate handles these.

Read limits are bounded by default

In v1, the default read limit was unbounded. This meant any caller, authenticated or not, could force a server to buffer a message of any size. To improve safety, v2 introduces a default limit of 4 MiB per message, which can be overridden using WithReadMaxBytes. Messages exceeding this limit fail with the CodeResourceExhausted error code.

To prevent unexpected runtime behavior changes during upgrades, the migration tool preserves the v1 unbounded behavior by default:

// v2, as the tool writes it
connecthttp.NewTransport(httpClient, baseURL, connecthttp.WithReadMaxBytes(0))
connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0))

✅ connect-go-v2-migrate handles this, and ⚠️ warns at every call it pins.

Options whose signature changed are warned with the v2 replacement:

v1 v2
connect.WithCodec(codec) connecthttp.WithCodecs(...connect.Codec), which replaces the default codecs instead of adding to them. List every codec you need in one call, including connectproto.NewBinaryCodec() if you still want it
connect.WithCompression(name, dec, comp) connecthttp.WithCompressors(...connect.Compressor) (a connectgzip-style compressor), which replaces the defaults. The (name, decompressor, compressor) signature is gone
connect.WithAcceptCompression(name, dec, comp) connecthttp.WithCompressors(...connect.Compressor); registered compressors are advertised automatically, and the order is the preference order, most preferred first. The nil constructors that removed one become an empty connecthttp.WithCompressors()
connect.WithConditionalHandlerOptions(fn) connecthttp.WithConditionalOptions(func(connect.Spec) []connecthttp.Option) (callback signature changed)
connect.NewNotModifiedError(header) connecthttp.NewNotModifiedError() (no header argument)

⚠️ Update these by hand.

Interceptors

v1 had one Interceptor interface with separate methods for unary calls, streaming clients, and streaming handlers. v2 replaces it with two function types, connect.ClientInterceptor and connect.ServerInterceptor, that wrap every RPC type uniformly:

func NewServerInterceptor(logger *slog.Logger) connect.ServerInterceptor {
	return func(next connect.ServerFunc) connect.ServerFunc {
		return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error {
			err := next(ctx, spec, stream)
			logger.InfoContext(ctx, "rpc completed",
				slog.String("procedure", spec.Procedure),
				slog.Any("error", err),
			)
			return err
		}
	}
}

The tool maps ecosystem interceptor constructors where it can:

  • ✅ validate.NewInterceptor() becomes validate.NewServerInterceptor() or validate.NewClientInterceptor(), depending on where it's used.
  • ⚠️ otelconnect.NewInterceptor() becomes otelconnect.NewServerInterceptor() or otelconnect.NewClientInterceptor(), which return an error. Assign the interceptor before constructing the server or client.
  • ⚠️ Custom interceptors must be rewritten by hand to the new function types. The tool warns at each one.

Streaming

Streaming generated code defines a named stream type per RPC (for example, PingServiceCumSumClientStream) instead of the v1 generics. Stream Send, Receive, and SendHeaders keep their v1 shape and take no context.Context; the call's context is bound when the stream is opened.

Stream metadata moves off the stream and onto the CallInfo, since the generated stream types no longer carry headers. A handler stream's RequestHeader()/ResponseHeader()/ResponseTrailer() become info.* on a seeded info, _ := connect.CallInfoForServerContext(ctx); a client stream's seed a connect.NewClientContext(ctx) and read from the returned info.

✅ connect-go-v2-migrate handles both.

The v1 receive loop, Receive() bool with Msg() and a trailing Err() check, becomes a Receive() call returning the message and an error. io.EOF marks the end of the stream:

// v1
for stream.Receive() {
	total += stream.Msg().Number
}
if err := stream.Err(); err != nil {
	return nil, err
}
// v2
for {
	msg, err := stream.Receive()
	if err != nil {
		if errors.Is(err, io.EOF) {
			break
		}
		return nil, err
	}
	total += msg.Number
}

✅ connect-go-v2-migrate handles this.

On the client side, stream constructors return an error and the close methods are renamed:

v1 v2
stream := client.CumSum(ctx) stream, err := client.CumSum(ctx)
stream.CloseRequest() stream.CloseSend()
stream.CloseResponse() stream.Close()
res, err := stream.CloseAndReceive() res, err := stream.CloseAndReceive() (returns the bare message)

✅ connect-go-v2-migrate handles this.

Handler stream parameters change the same way. A v1 handler takes a generic like *connect.BidiStream[Req, Res], while v2 passes a named type generated per RPC (like PingServiceCumSumServerStream):

// v1
func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error

// v2
func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error

✅ connect-go-v2-migrate resolves the parameter type to the generated stream type by matching the handler to its RPC. ⚠️ When several services share an RPC name and message types, the match is ambiguous; the tool warns so you can pick the right generated type by hand.

Helper packages

Some shared helpers need a new sibling API rather than an in-place rewrite, for example a helper that returns a v1 UnaryInterceptorFunc becoming a v2 ServerInterceptor. ⚠️ This is project-specific: add the v2 helper alongside the v1 one, then switch call sites to it by hand.

Ecosystem packages

Each ecosystem package releases a connect-go v2 build. grpchealth and grpcreflect move to /v2 module paths. Most reshape their API to register on a *connect.Server, so interceptors and alternative transports cover them. The sections below show each change.

validate

validate.NewInterceptor splits into server and client forms:

// v1
connect.WithInterceptors(validate.NewInterceptor())
// v2
connect.NewServer(validate.NewServerInterceptor())

✅ connect-go-v2-migrate handles this, picking the server or client form from where the interceptor is used.

otelconnect

otelconnect.NewInterceptor also splits into NewServerInterceptor and NewClientInterceptor. Both still return an error, so assign them before constructing the server or client:

// v1
interceptor, err := otelconnect.NewInterceptor()
// v2
serverInterceptor, err := otelconnect.NewServerInterceptor()

⚠️ The tool can't tell the server side from the client side at the assignment, so it warns and leaves the call for you to pick the right form.

authn

authn stays on v1. It is plain net/http middleware, so it keeps working in front of a v2 server without changes. The tool leaves authn imports unchanged.

grpchealth

The health service registers on the server instead of wrapping a mux:

// v1
mux.Handle(grpchealth.NewHandler(checker))
// v2
grpchealth.Register(server, checker)

✅ connect-go-v2-migrate handles the inline mux.Handle(...) shape. A health handler registered alongside your services with the same options joins their server; with different options it gets its own server, keeping its v1 interceptor behavior.

The health client changes like the generated clients:

// v1
client := grpchealth.NewClient(http.DefaultClient, "http://localhost:8080")
// v2
client := grpchealth.NewClient(connect.NewClient(
	connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"),
))

✅ connect-go-v2-migrate handles this.

grpcreflect

Reflection registers on the server with one call. Register serves both the v1 and v1alpha reflection APIs, and by default describes the services registered on the server, so the static service list usually disappears:

// v1
reflector := grpcreflect.NewStaticReflector("acme.user.v1.UserService")
mux.Handle(grpcreflect.NewHandlerV1(reflector))
mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))
// v2
grpcreflect.Register(server)

To expose a different set of services, for example when proxying, pass grpcreflect.WithNamer.

⚠️ The tool warns at each NewHandlerV1, NewHandlerV1Alpha, and NewStaticReflector call; collapse them to one Register by hand.

The reflection client changes like the generated clients:

// v1
client := grpcreflect.NewClient(http.DefaultClient, "http://localhost:8080")
// v2
client := grpcreflect.NewClient(connect.NewClient(
	connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"),
))

✅ connect-go-v2-migrate handles this.

ClientStream.Close returns only an error. WithRequestHeaders and the stream's Spec, Peer, and ResponseHeader methods are removed. Set and read metadata through the context passed to NewStream:

// v1
stream := client.NewStream(ctx, grpcreflect.WithRequestHeaders(header))
_, err := stream.Close()
// v2
ctx, info := connect.NewClientContext(ctx)
info.RequestHeader().Set("X-Test", "1")
stream := client.NewStream(ctx)
err := stream.Close()

✅ connect-go-v2-migrate drops the unused header result of Close.

⚠️ The tool warns at WithRequestHeaders, at a Close whose header is used, and at Spec, Peer, and ResponseHeader calls.

vanguard

REST transcoding mounts the server's REST routes directly; the Transcoder and Service types are gone. Methods registered on the server whose descriptors carry a google.api.http annotation become REST endpoints:

// v1
services := []*vanguard.Service{
	vanguard.NewService(pingv1connect.PingServiceName, handler),
}
transcoder, err := vanguard.NewTranscoder(services)
mux.Handle("/", transcoder)
// v2
err := vanguard.Mount(mux, server)

For gRPC servers, vanguardgrpc.NewTranscoder(grpcServer) becomes vanguardgrpc.NewServiceRegistrar(server), which registers gRPC service implementations on a *connect.Server.

⚠️ Update vanguard by hand. The v2 API changes substantially and needs new configuration, so the tool leaves the import unchanged and warns at the import and each v1 call site.

Testing with the in-process transport

v2 clients are not tied to HTTP, so tests no longer need a listener or an httptest.Server. The connectinprocess package connects a client directly to a server in the same process:

func TestPingService(t *testing.T) {
	server := connect.NewServer()
	pingv1connect.RegisterPingServiceHandler(server, &pingServer{})

	client := connect.NewClient(connectinprocess.New(server))
	pingClient := pingv1connect.NewPingServiceClient(client)

	res, err := pingClient.Ping(t.Context(), &pingv1.PingRequest{Text: "hello"})
	if err != nil {
		t.Fatalf("Ping: %v", err)
	}
	if got, want := res.GetText(), "hello"; got != want {
		t.Errorf("Text = %q, want %q", got, want)
	}
}

This is not a required migration step, but in-process tests are faster and less flaky than tests over a loopback listener.

Getting help

If your migration hits a case not covered here, or the tool produces a result you didn't expect, please open an issue or ask in Slack.