Skip to content

gRPC Driver

The gRPC driver adapts generated Anvil gRPC services to google.golang.org/grpc.

Install it:

Terminal window
go get github.com/TDB-Group/anvil-grpc

Import it as:

import anvilgrpc "github.com/TDB-Group/anvil-grpc"

Native gRPC server options go directly to the driver:

app := anvil.New(
anvilgrpc.Driver(
grpc.MaxRecvMsgSize(16 << 20),
),
)

When installed with HTTP and GraphQL drivers, Anvil’s edge listener accepts the connection and dispatches gRPC requests to this driver when the parsed request is HTTP/2 and Content-Type starts with application/grpc.

The driver also exposes standalone Start, Serve, and ServeConn paths for tests and low-level integrations. In a normal multi-protocol app, Anvil serves the public listener and calls the gRPC driver through ServeHTTP.

Shutdown(ctx) starts grpc.Server.GracefulStop and waits for one of three outcomes. If graceful stop finishes first, it returns nil. If ctx is canceled first, the driver calls Stop and returns the context error. If GracefulStopTimeout expires first, the driver calls Stop and returns nil.

Use DriverWithOptions for the full configuration surface:

app := anvil.New(
anvilgrpc.DriverWithOptions(anvilgrpc.Options{
ServerOptions: []grpc.ServerOption{
grpc.MaxRecvMsgSize(16 << 20),
},
GracefulStopTimeout: 15 * time.Second,
ServiceDescriptors: []protoreflect.ServiceDescriptor{projectDesc},
}),
)

Supported options:

OptionBehavior
ServerOptionsNative grpc.ServerOption values.
ErrorPipelineError mapper and observer pipeline. Driver and DriverWithOptions fill this from app.ErrorPipeline() when the option is nil.
GracefulStopTimeoutTime allowed for grpc.Server.GracefulStop before the driver calls Stop. Defaults to 10s.
ServiceDescriptorsProtobuf service descriptors used for metadata validation.
RequireServiceDescriptorsFails registration when a generated service has no matching descriptor.

Nil ErrorPipeline options are replaced with a default pipeline before validation, or with the owning app pipeline when installed through Driver or DriverWithOptions. The driver rejects a negative graceful stop timeout. GracefulStopTimeout: 0 selects the driver default of 10s.

DriverFrom installs an existing transport and sets that transport’s error pipeline to the owning app pipeline. Use anvilgrpc.NewWithOptions(anvilgrpc.Options{ErrorPipeline: ...}) when the transport is running standalone or when a test needs to inspect the transport before it is installed.

Native grpc.ServerOption values stay native. Unary and stream interceptors registered through gRPC server options run at the gRPC server layer. Anvil’s generated middleware runs inside the registered service method after the gRPC request has reached the Anvil service descriptor.

For unary calls, a gRPC unary interceptor receives a handler that runs the Anvil middleware chain and generated handler. For streaming calls, gRPC stream interceptors wrap the generated stream handler at the gRPC server layer. An interceptor can reject a call before Anvil middleware runs.

ServerOptions and ServiceDescriptors are copied when the transport is created. Mutating the caller-owned slices after New, Driver, or DriverWithOptions does not change the running driver.

Application services embed sdk.GrpcEndpoint:

type ProjectService struct {
sdk.GrpcEndpoint `service:"anvil.sample.ProjectService"`
Store project.Store `inject:"read"`
}

Unary handlers use:

func (s *ProjectService) GetProject(
ctx context.Context,
req *pb.GetProjectRequest,
) (*pb.Project, error)

Server streaming handlers use:

func (s *ProjectService) StreamProjects(
ctx context.Context,
req *pb.StreamProjectsRequest,
stream sdk.GRPCServerStream[*pb.Project],
) error

Client streaming and bidirectional streaming use the corresponding SDK stream contracts:

sdk.GRPCClientStream[*pb.Request]
sdk.GRPCBidiStream[*pb.Request, *pb.Response]

Generated code converts those typed stream contracts to the driver’s untyped sdk.GRPCStream bridge.

Registration fails before the service is exposed when:

  • The service name is empty.
  • The service has no unary or streaming methods.
  • The service name was already registered on the same driver.
  • A unary or streaming method name is empty.
  • Two generated methods on the same service use the same name.
  • A unary method has no request factory or handler.
  • A server-streaming method has no request factory.
  • A streaming method has an invalid stream kind or no handler.
  • Middleware on a method does not implement sdk.GRPCMiddleware.

gRPC middleware uses HandleGRPC and applies to unary and streaming methods:

func (RPCTrace) HandleGRPC(ctx sdk.GRPCCtx) (any, error) {
method := ctx.FullMethod()
kind := ctx.StreamKind()
_ = method
_ = kind
response, err := ctx.Next()
if err != nil {
return nil, err
}
return response, nil
}

HandleGRPC is Anvil’s generated middleware method. It is not a native grpc.UnaryServerInterceptor or grpc.StreamServerInterceptor. Native gRPC interceptors still belong in grpc.ServerOption values passed to the driver; Anvil middleware runs after the request reaches the generated service method.

Use gRPC middleware when the code needs Anvil’s generated service metadata: service name, method name, stream kind, decoded unary request, and generated dependency wiring. Use native gRPC interceptors when the code belongs at the raw gRPC server layer, before Anvil’s generated service method is entered.

Attach gRPC middleware to a parent group:

type V1 struct {
sdk.Group `path:"/v1"`
_ sdk.Use[middleware.RPCTrace]
ProjectService *rpc.ProjectService
}

Generated gRPC middleware is group-scoped. Put service-wide middleware on the nearest group that contains the service. The SDK has no per-method gRPC policy marker.

ctx.Next() can be called once. Calling it twice returns an internal sdk.Failure.

HTTP middleware does not run for gRPC methods. Even though gRPC uses HTTP/2 on the wire, Anvil dispatches it as gRPC and applies only sdk.GRPCMiddleware components.

Use ctx.StreamKind() when middleware needs different behavior for unary, server-streaming, client-streaming, or bidirectional streaming calls. Use ctx.Stream() only for middleware that truly needs raw stream access. Normal application handlers use the typed stream contracts generated for their method signatures.

Unary methods report sdk.GRPCStreamUnknown from ctx.StreamKind(). Streaming methods report sdk.GRPCStreamServer, sdk.GRPCStreamClient, or sdk.GRPCStreamBidi.

For unary and server-streaming methods, ctx.Request() contains the decoded request message. For client-streaming and bidirectional streaming methods, ctx.Request() is nil and the middleware reads messages through ctx.Stream() only when it intentionally needs raw stream access.

Middleware runs in inherited group order. The first middleware receives the call, its ctx.Next() calls the next middleware, and the final continuation calls the generated RPC handler. Returning without ctx.Next() stops the RPC before the generated handler runs. That is useful for auth, deadline policy, or load-shedding decisions.

Read Middleware for the full execution model and sdk.GRPCCtx fields.

The driver maps generated stream shapes onto gRPC as follows:

Stream kindDriver behavior
UnaryAllocates a request with NewRequest, decodes it, then sends the returned response.
Server streamingAllocates and receives one request before calling the handler. The handler sends responses through the typed server stream.
Client streamingPasses nil request and a receive-capable stream to the handler. The handler returns one response, and the driver sends it after the handler finishes.
Bidirectional streamingPasses nil request and a send/receive stream to the handler. The handler controls both receive and send.

If a request factory returns nil, or a client-streaming handler returns nil for its final response, the driver maps that as an internal Anvil failure.

For server-streaming methods, the driver receives one request message before it calls the handler. For client-streaming and bidirectional methods, the driver passes nil request data to the handler and exposes message receive through the stream contract.

Client-streaming handlers return one final response. After the handler returns, the driver sends that response with stream.Send. Server-streaming and bidirectional handlers send their own responses through the stream and the driver ignores the returned any value.

The driver maps Anvil failures to gRPC status codes:

HTTP status in sdk.FailuregRPC code
400, 422InvalidArgument
401Unauthenticated
403PermissionDenied
404NotFound
409AlreadyExists
412FailedPrecondition
429ResourceExhausted
501Unimplemented
503Unavailable
504DeadlineExceeded
Any other statusInternal

Decode errors become 400 Bad Request failures before they are mapped to gRPC. Handler and middleware panics are recovered and mapped through the same error pipeline as handler-phase errors.

Because recovered gRPC panics are published with handler phase, event.Recovered is false for those events. The failure cause still contains the panic text and the gRPC response is still an internal status unless a custom mapper changes the classification. HTTP drivers use panic phase for recovered HTTP and WebSocket panics; gRPC does not.

The status message is the mapped Anvil failure message. The driver publishes the mapped failure before returning the gRPC status error.

If the standalone gRPC server returns grpc.ErrServerStopped, the driver normalizes that to nil. That keeps ordinary graceful stops from surfacing as application errors.

When a descriptor’s full service name matches a generated service name, the driver validates generated service metadata against that descriptor before registering the service. This catches schema drift between generated Anvil metadata and .proto definitions.

Set RequireServiceDescriptors when production builds must fail if a service is missing descriptor coverage.

Validation checks:

  • The generated service must have a matching descriptor when descriptors are required.
  • Descriptor entries cannot be nil, and descriptor names cannot be duplicated.
  • Extra descriptors for services outside the Anvil route tree are ignored.
  • Every generated unary method must exist on the descriptor.
  • Every generated stream method must exist and match the generated stream kind.
  • The descriptor method count must match the total generated unary and stream method count for that service.

anvilgrpc.RunTestbed(ctx, suite, options) executes gRPC cases from an Anvil testbed suite. The runner lives in the gRPC driver because it needs gRPC client and protobuf descriptor support.

report, err := anvilgrpc.RunTestbed(ctx, suite, anvilgrpc.TestbedOptions{
Target: "127.0.0.1:8080",
DialOptions: []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
},
Descriptors: []protoreflect.ServiceDescriptor{projectDescriptor},
})

TestbedOptions.Conn lets callers provide an existing client connection. Target and DialOptions are used when Conn is nil. Descriptors are used to map JSON-shaped testbed messages into protobuf messages. Timeout applies per testbed call when it is positive. When it is zero or negative, the runner uses the caller’s context without adding a deadline. When Conn is nil, Target is required and the runner opens a grpc.ClientConn with grpc.NewClient; that connection is closed by the runner. A caller-provided Conn is not closed.

The testbed runner needs descriptors for the service and method under test. It uses them to build dynamic protobuf messages from JSON-shaped case input and to convert protobuf responses into JSON-shaped maps. Unary cases call ClientConn.Invoke. Streaming cases use ClientConn.NewStream with stream flags derived from the manifest kind, send every item in input.messages or one input.message when messages is empty, call CloseSend, then read responses until EOF. Expected messages are compared as JSON-shaped maps after actual protobuf responses have been converted through protojson.

The streaming runner is batch-oriented. It sends all configured input messages first, closes the client send side, and then reads all responses. It does not interleave send and receive steps inside one case. If expect.message and expect.messages are both empty, the runner checks only the gRPC status code.