Skip to content

acycl/qrpc

Repository files navigation

qrpc

A lightweight RPC framework for Go that runs directly over QUIC streams. It uses Protocol Buffers for service definitions and includes a protoc plugin that generates type-safe client and server stubs in the same style as gRPC-Go — server interfaces, registration functions, and typed client constructors.

Why QUIC without HTTP?

gRPC sends protobuf messages through several layers of framing: protobuf payloads are wrapped in gRPC length-prefixed messages, carried inside HTTP/2 DATA frames with HPACK-compressed headers, and terminated by HTTP trailers that encode grpc-status and grpc-message. All of this runs over a TCP connection with a separate TLS handshake. Each layer adds complexity, overhead, and code that exists only to satisfy the requirements of the layer above it.

QUIC eliminates the need for most of these layers. It provides multiplexed, independent streams with TLS 1.3 built into the handshake — the same core properties that HTTP/2 was designed to provide over TCP, but at the transport level. qrpc uses QUIC streams directly as the delivery mechanism for RPCs: each call opens a dedicated stream, writes a compact binary frame, and reads the response. There is no HTTP framing, no HPACK, no pseudo-headers, and no trailer-encoded status codes. The protocol negotiation that HTTP would normally handle is replaced by a single ALPN token (qrpc) exchanged during the QUIC handshake.

This means fewer allocations per call, less parsing, and a smaller dependency surface. The entire wire format is a handful of length-prefixed fields — simple enough to describe in a few lines and implement without a framing library.

Features

  • QUIC transport — multiplexed streams over a single connection with no head-of-line blocking
  • Protobuf code generationprotoc-gen-qrpc generates familiar gRPC-style client interfaces, server interfaces, and registration glue from .proto files
  • Transparent reconnectionClientConn automatically re-establishes the underlying QUIC connection on failure
  • Minimal wire format — compact binary framing with no HTTP dependency
  • Buffer poolingsync.Pool-backed buffers minimize per-RPC heap allocations
  • Context cancellation — client-side context cancellation propagates to stream teardown

Installation

go get github.com/acycl/qrpc

Install the protoc plugin:

go install github.com/acycl/qrpc/cmd/protoc-gen-qrpc@latest

Or add it as a tool dependency in your go.mod:

go get -tool github.com/acycl/qrpc/cmd/protoc-gen-qrpc

This lets you invoke it via go tool protoc-gen-qrpc without a global install, which is especially useful with Buf.

Quick start

1. Define a service

// greeter.proto
syntax = "proto3";
package greeter;
option go_package = "example.com/greeter";

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

2. Generate Go code

protoc --go_out=. --go_opt=paths=source_relative \
       --qrpc_out=. --qrpc_opt=paths=source_relative \
       greeter.proto

This produces greeter.pb.go (protobuf messages) and greeter_qrpc.pb.go (client/server stubs).

The generated code follows the same pattern as gRPC-Go:

  • A server interface (GreeterServer) that you implement
  • A registration function (RegisterGreeterServer) to wire it up
  • A client interface (GreeterClient) with a constructor (NewGreeterClient) backed by a *qrpc.ClientConn

3. Implement the server

package main

import (
	"context"
	"crypto/tls"
	"log"

	"github.com/acycl/qrpc"
	pb "example.com/greeter"
)

type greeterServer struct{}

func (s *greeterServer) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
	return &pb.HelloReply{Message: "Hello, " + req.Name + "!"}, nil
}

func main() {
	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{mustLoadCert()},
	}

	srv := qrpc.NewServer()
	pb.RegisterGreeterServer(srv, &greeterServer{})

	ctx := context.Background()
	if err := srv.ListenAndServe(ctx, "127.0.0.1:4242", tlsConfig); err != nil {
		log.Fatal(err)
	}
}

4. Write a client

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"log"

	"github.com/acycl/qrpc"
	pb "example.com/greeter"
)

func main() {
	tlsConfig := &tls.Config{
		ServerName: "localhost",
		// RootCAs: ...,
	}

	conn, err := qrpc.Dial(context.Background(), "127.0.0.1:4242", tlsConfig)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	client := pb.NewGreeterClient(conn)
	reply, err := client.SayHello(context.Background(), &pb.HelloRequest{Name: "World"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(reply.Message) // Hello, World!
}

Using Buf

If you use Buf for protobuf management, you can invoke protoc-gen-qrpc via go tool so that your project's pinned version is always used. Add it as a tool dependency (see Installation), then configure buf.gen.yaml:

version: v2
plugins:
  - protoc_path: ["go", "tool", "protoc-gen-go"]
    out: .
    opt: paths=source_relative
  - protoc_path: ["go", "tool", "protoc-gen-qrpc"]
    out: .
    opt: paths=source_relative

Then generate with:

buf generate

Wire format

Each unary RPC occupies a single QUIC stream. The client writes a request frame and closes the write side of the stream; the server writes a response frame and the stream is complete. No additional framing, content negotiation, or status trailers are needed because QUIC itself handles stream boundaries, flow control, and encryption.

Request frame:

[4 bytes: method length] [4 bytes: payload length] [method] [payload]

The method is the fully-qualified protobuf method path (e.g. /greeter.Greeter/SayHello). The payload is the serialized protobuf request message.

Response frame (success):

[1 byte: 0x00] [4 bytes: payload length] [payload]

Response frame (error):

[1 byte: 0x01] [4 bytes: message length] [error message]

The single status byte replaces gRPC's trailer-encoded grpc-status and grpc-message fields. Errors are transmitted as plain UTF-8 strings.

API overview

Server

Function / Method Description
qrpc.NewServer() Create a new server instance
srv.RegisterService(desc) Register a service (called by generated code)
srv.HandlerTimeout Per-RPC timeout; zero means no limit
srv.ListenAndServe(ctx, addr, tlsConfig) Listen on an address and serve RPCs
srv.Serve(ctx, listener) Serve RPCs on an existing quic.Listener

Client

Function / Method Description
qrpc.Dial(ctx, addr, tlsConfig) Connect to a qrpc server
cc.Invoke(ctx, method, req, reply) Make a unary RPC call (called by generated code)
cc.Close() Close the connection

The client transparently reconnects if the underlying QUIC connection is lost. Concurrent callers coordinate so that only one reconnection attempt is made.

Code generation

The generated code provides typed wrappers so you don't call Invoke directly:

  • RegisterXxxServer(srv, impl) — registers your implementation with the server
  • NewXxxClient(cc) — returns a typed client backed by a *qrpc.ClientConn

QUIC configuration

The default QUIC configuration is tuned for RPC workloads:

Parameter Value
Max concurrent streams 65,536
Idle timeout 60 s
Keep-alive period 15 s
Initial stream receive window 1 MiB
Max stream receive window 8 MiB
Max connection receive window 64 MiB
Max payload size 16 MiB

Running tests

go test ./...

Run benchmarks:

go test -bench=. -benchmem ./...

License

See LICENSE for details.

About

A lightweight RPC framework for Go that runs over QUIC.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Contributors

Languages