Skip to content

generate connect clients and handlers alongside twirp - #75

Merged
hugowetterberg merged 10 commits into
mainfrom
feature/connect
Sep 8, 2026
Merged

generate connect clients and handlers alongside twirp#75
hugowetterberg merged 10 commits into
mainfrom
feature/connect

Conversation

@hugowetterberg

@hugowetterberg hugowetterberg commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The platform is moving from Twirp to Connect with a long dual-stack period, and this module declares the APIs the core services serve. Every service now ships Connect clients and handlers next to the Twirp ones, generated with the released ttab/mage v0.13.1, which pins protoc-gen-elephant-rpc to elephantine v0.29.0.

New protocol surface. Per service a <package>connect subpackage (repository/repositoryconnect, index/indexconnect, spell/spellconnect, user/userconnect, replicant/replicantconnect) with New<Service>ServiceClient(httpClient, baseURL), returning the same plain service interface New<Service>ProtobufClient returns, and New<Service>ServiceHandler(svc, opts...), returning the mount path and handler for elephantine's RegisterConnect. connect-go's own New<Service>Client and New<Service>Handler are generated too; the Service infix tells them apart. The Connect paths are the unprefixed /<package>.<Service>/<Method>; the Twirp clients, servers and /twirp/ paths are unchanged. repositorysocket declares no service and gains a README describing the websocket protocol instead.

Wire facts a consumer will notice. Connect error bodies are {"code","message","details"} with the key/value metadata as an elephantine.rpc.ErrorMeta detail; the codes and messages are identical on both stacks, and three HTTP statuses differ (failed_precondition 400 against 412, canceled 499 against 408, deadline_exceeded 504 against 408). Connect JSON responses spell field names in lowerCamelCase where Twirp uses the proto names; requests accept both. gRPC and gRPC-Web are served on the Connect paths inside the cluster only.

Removed. The OpenAPI specifications under docs/: nobody generated a client from them, they described the Twirp surface only, and the generator cannot run under buf. A release is now a plain tag with no bump commit.

Build. Generation runs through the rpc mage namespace with buf and pinned plugins, no Docker; mage rpc:generate reproduces the committed output byte for byte. The module requires connectrpc.com/connect v1.20.0 and does not depend on elephantine. Go 1.27.1. The first regeneration rewrote every .pb.go for the newer protoc-gen-go; the embedded descriptors were verified identical to main's.

Rebased onto v0.24.2, so the user Configuration service added there has its adapters and tests. The unreleased CHANGELOG section has the detail per lead-in.

The fleet reference for how Connect is served, called, tested and generated is elephantine's docs/connect.md; the playbooks for moving a service and a client are docs/migration-service.md and docs/migration-client.md.

Every consumer of these declarations needs the Connect clients and handlers
before it can go dual stack, so this is the release the rest of the migration
waits on. The generated `<package>connect` packages hold connect-go's own
client and handler plus the adapters that put them on the plain protobuf
service interface, which is the contract implementations and Go callers
already use — so switching a client is a constructor call and switching a
server is a mount line, with no handler signatures touched.

Generation moves from protoc in the elephant-twirptools image to buf with
plugins pinned in ttab/mage and run as "go run <module>@<version>", so it
needs no Docker and installs nothing. The targets are renamed to match, and
newsdoc/newsdoc.proto declares no service, so the rpc targets do not discover
it and the Newsdoc target compiles it itself before regenerating the services
that embed its messages.

The generator move rewrites every .pb.go, because protoc-gen-go v1.36.12
emits the embedded descriptor as a string constant where v1.36.2 emitted a
byte slice, and rewrites the gzipped descriptor blob in every service.twirp.go
because Go's gzip encodes differently from protoc's. The compiled descriptors
themselves are byte for byte identical to the ones the image produced, and the
OpenAPI specifications come out unchanged, so nothing in the API moved.

The module gains connectrpc.com/connect and stays free of elephantine: a
declarations module that every service and the non-Go tooling depends on must
not drag pgx, prometheus, oauth2 and vault behind it. Error helpers, header
propagation and the ErrorMeta detail live in elephantine/rpc instead.

The ttab/mage requirement is pinned to a local checkout while the rpc
namespace is unreleased.
The README described `mage rpc:generate` as writing the
`<package>connect/service.elephant.go` adapters, but ttab/mage skips
protoc-gen-elephant-rpc while it has no released version to pin, and does so
without an error. Anyone editing a .proto and regenerating today would get
fresh messages, Connect and Twirp code beside adapters that were never rerun,
and nothing would say so — CI does not regenerate, so the drift would reach a
tag.

Say what the override is, and record that a release waits for the elephantine
release that ships the plugin and the ttab/mage release that pins it, which is
the sequencing the migration already calls for.
The specifications under docs/ described the Twirp paths and Twirp's
error schema only, nobody generated a client from them, and the
generator that wrote them cannot run under buf. The .proto files are
the declaration a non-Go consumer generates from.

With nothing left to stamp a version into, a release is a plain git tag:
there is no rpc:release target and no "bump to vX.Y.Z" commit.
elephant-docs renders each API's README.md as the introduction of its
page, and repositorysocket had none. It is the one API that is neither
Twirp nor Connect, so its page says so: the messages travel over the
repository's websocket, and the error codes are the RPC code strings.
The replace directive pointed at a sibling checkout while the rpc
namespace was unpushed. It is pushed now and pins protoc-gen-elephant-rpc
itself, so the requirement becomes the pseudo-version of that commit and
the documentation stops telling people to regenerate with the plugin
override.
The Configuration service arrived on main in v0.24.2 while the Connect
branch was in flight. The rebase regenerated its adapters; this pins them
the way the other services' are pinned, and lists the constructor.
Decision 11 of the Connect migration puts every module in this round on the
latest Go 1.27 patch. For a declarations module the directive is not a build
detail: it forces the floor of every consumer that imports the message types,
so it is recorded as its own outermost CHANGELOG lead-in rather than as a
sentence inside the generation paragraph.

google.golang.org/protobuf moves to v1.36.12, the runtime version the pinned
protoc-gen-go already names in the generated headers, so go.mod and the
CHANGELOG agree with what the code says.

ttab/mage is re-pinned at the rpc namespace commit that runs protoc-gen-twirp
from a module it carries, under a pinned Go toolchain, so the committed output
no longer depends on the ambient toolchain. Regenerating under the new pins
reproduces the committed files byte for byte.
The adversarial review found three promises in the shipped documentation that
the code does not keep.

A Connect JSON response is not a Twirp one with a different path. Twirp
marshals with UseProtoNames, Connect with protojson's defaults, so every
multi-word field is spelled lowerCamelCase on the Connect paths. Decision 9
keeps the standard encoding rather than installing a UseProtoNames codec, so
the difference is documented instead: it is the whole cost for a caller that
reads JSON by hand, and nothing at all for a generated client.

gRPC and gRPC-Web are served on the Connect paths, but the platform's ingress
speaks HTTP/1.1 to its targets, so they stop at the cluster edge. Decision 14
leaves it that way, so the README and CHANGELOG say in-cluster rather than
implying an API a customer can call.

The mage and elephantine branches pin each other, so the release order in the
README was impossible as written. It now names the four steps, and the reason
mage has to be tagged first.
The module moved to go 1.27.1 and the v2.11 binary the lint job used is
built with an older Go, which refuses a target newer than itself. v2.13
is built with Go 1.27 and the code lints clean under it.
The rpc namespace and the plugin it pins are released, so the
requirement moves from a pseudo-version of the feature branch to the
tag. The generated code is unchanged, and the README describes the
generators as released rather than listing a tagging order.

@danijelvukoje danijelvukoje left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks great. It will make the transition painless. 🙂

@hugowetterberg
hugowetterberg merged commit a0e6495 into main Sep 8, 2026
2 checks passed
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.

2 participants