Demo for doing RPC in python using buf and ConnectRPC, and ty for type-checking.
Find a file
2026-09-08 20:06:13 +02:00
docs README+charts: add rust-connect-demo (Connect over HTTP/1.1 in Rust, 118k req/s) 2026-09-08 20:06:13 +02:00
gen first commit 2026-09-07 22:09:09 +02:00
proto/greet/v1 first commit 2026-09-07 22:09:09 +02:00
.gitignore Systematic benchmarks + Connect over HTTP/2 2026-09-08 16:08:40 +02:00
.python-version first commit 2026-09-07 22:09:09 +02:00
benchmark.py README: add golang-quic-rpc (Connect over HTTP/3) to benchmark comparison 2026-09-08 18:26:02 +02:00
benchmark_h2.py README: add golang-quic-rpc (Connect over HTTP/3) to benchmark comparison 2026-09-08 18:26:02 +02:00
buf.gen.yaml first commit 2026-09-07 22:09:09 +02:00
buf.yaml first commit 2026-09-07 22:09:09 +02:00
charts.py README+charts: add rust-connect-demo (Connect over HTTP/1.1 in Rust, 118k req/s) 2026-09-08 20:06:13 +02:00
client.py first commit 2026-09-07 22:09:09 +02:00
pyproject.toml Add benchmark charts (matplotlib, PNG) to README 2026-09-08 19:41:34 +02:00
README.md README+charts: add rust-connect-demo (Connect over HTTP/1.1 in Rust, 118k req/s) 2026-09-08 20:06:13 +02:00
server.py first commit 2026-09-07 22:09:09 +02:00
server_h2.py Systematic benchmarks + Connect over HTTP/2 2026-09-08 16:08:40 +02:00
uv.lock Add benchmark charts (matplotlib, PNG) to README 2026-09-08 19:41:34 +02:00

pyrpc-demo

A minimal demo of a Python server and client communicating over protobuf using Buf (schema tooling) and Connect RPC (the RPC protocol and runtime). Everything is managed with uv — no global installs required.

What the pieces are

Piece Role in this project
uv Python project/package manager. Owns the virtualenv and lockfile, and runs everything via uv run.
Buf (buf-bin) Protobuf-native toolchain. Lints the schema, checks breaking changes, and generates Python code from .proto files.
protobuf (protobuf-py) The serialization format. The new protobuf-py runtime (bundled as a dependency of connectrpc) powers the generated message classes. Pure Python at the core, accelerated by an optional Rust extension (protobuf-py-ext).
Connect RPC (connectrpc) The RPC framework. Defines the protocol (plain HTTP/1.1 + protobuf bodies) and provides the server (ASGI) and client runtimes.

Connect's key property for this demo: it's just HTTP. The client sends requests to http://host/greet.v1.GreetService/<Method>; no gRPC/HTTP2 infrastructure is needed. Unary and server-streaming calls work over plain HTTP/1.1 POST (chunked responses for streaming); client- and bidi-streaming use the Connect streaming envelope (still just HTTP bodies).

Dependencies (pyproject.toml)

Runtime ([project] dependencies) — what server.py and client.py import:

Dependency Why it's here
connectrpc Connect RPC runtime for Python. One package provides both the client (ConnectClient) and the server (ConnectASGIApplication). Pulls in protobuf-py and pyqwest (its HTTP layer) automatically.
uvicorn ASGI server that runs the Connect application.
greet The generated code, packaged as a uv workspace member (see below).

Development ([dependency-groups] dev) — tools used to build and check the project, never imported by the running code:

Dependency Why it's here
buf-bin The Buf CLI, installed inside the project's venv instead of globally (via Homebrew/MacPorts). Used only for uv run buf lint / uv run buf generate.
ty Astral's type checker, for static checking of server/client against the generated stubs.
psutil Process/socket introspection; used by benchmark.py to count live TCP connections.
hypercorn HTTP/2-capable ASGI server for server_h2.py (TLS + ALPN).

Note: there is no gRPC dependency. Connect is a separate protocol that carries protobuf over ordinary HTTP.

Project layout

proto/greet/v1/greet.proto   # the schema: greet.v1.GreetService (4 RPC modes)
buf.yaml                     # buf module config (STANDARD lint, FILE breaking rules)
buf.gen.yaml                 # buf code-generation config
gen/                         # generated code (uv workspace member, see below)
  pyproject.toml            #   makes it an installable `greet` package
  greet/v1/greet_pb.py      #   message classes (GreetRequest, GreetResponse)
  greet/v1/greet_connect.py #   service stubs (ASGI app + typed client)
server.py                    # Connect RPC server (uvicorn/ASGI, HTTP/1.1)
server_h2.py                 # same app over HTTP/2 (Hypercorn, TLS)
client.py                    # Connect RPC client
benchmark.py                 # performance benchmark (codec + RPC throughput)
benchmark_h2.py              # throughput benchmark over HTTP/2
certs/                       # demo CA + server cert for the HTTPS/h2 server

Setup

uv sync

This creates the virtualenv and installs everything, including buf itself.

Generate code

uv run buf lint        # lint the .proto schema
uv run buf generate    # write gen/greet/v1/*

buf.gen.yaml uses two Buf remote plugins:

  • buf.build/bufbuild/py — generates the message classes using the modern protobuf-py runtime (greet_pb.py). This is required by connectrpc 0.12+; the classic buf.build/protocolbuffers/python plugin (which targets google.protobuf) is not compatible by default.
  • buf.build/connectrpc/py — generates the Connect service stubs (greet_connect.py): a GreetServiceASGIApplication for serving and a typed GreetServiceClient for calling.

Regenerate whenever you change greet.proto.

The generated code as a package

gen/ is a uv workspace member: it has its own pyproject.toml declaring the greet package (built with uv_build), and the root project depends on it via tool.uv.sources (greet = { workspace = true }). This has two benefits over hacking PYTHONPATH:

  1. uv run server.py just works — the generated code is importable.
  2. Type checkers (like ty) resolve greet imports as a normal installed package.

Run

Terminal 1 (server):

uv run server.py

Terminal 2 (client):

uv run client.py

Expected output:

[client] got response: Hello, world!

(uv sync installs gen/ into the venv, so no PYTHONPATH tricks needed.)

Benchmark

benchmark.py measures codec (de)serialization and RPC throughput. Start the server, then run the benchmark:

uv run server.py        # terminal 1
uv run benchmark.py     # terminal 2

It measures:

  1. Codec — in-process serialize/deserialize of GreetRequest at 64 / 128 / 256 / 512 / 1024 / 2048 KiB payloads, each timed to run at least 0.5 s per direction.
  2. Unary throughput vs concurrency — 2,000 Greet round-trips at each concurrency level (1, 4, 16, 64), reporting req/s, amortized µs/req, peak TCP connections, and speedup vs concurrency 1. Connections are sampled live from the benchmark process via psutil (a dev dependency).
  3. Unary throughput, large payloads — 200 concurrent Greet round-trips (concurrency 8, 2 MiB payloads), reporting MB/s of request data.
  4. Bidi streaming throughput — 5,000 messages over a single GreetChat stream.

Sample results (M-series MacBook, 2026-09)

The tables below are also charted inline (PNGs in docs/, regenerate with uv run charts.py).

Compared against the sibling repos — pygrpc-demo (gRPC + classic protobuf, Python), rust-grpc-demo (tonic + prost, Rust), golang-grpc-demo (grpc-go + protobuf-go, Go), golang-quic-rpc (Connect over HTTP/3 / quic-go, Go), rust-connect-demo (Connect + connectrpc/buffa, Rust) — all with the same schema and benchmark methodology:

Codec (µs/op, MB/s in parentheses):

payload ser py ser py-gRPC ser Rust ser Rust-C ser Go deser py deser py-gRPC deser Rust deser Rust-C deser Go
64 KiB 3.7 2.2 1.0 1.0 5.0 3.3 3.3 2.0 1.4 4.4
128 KiB 5.4 4.2 1.9 1.9 10.3 6.4 6.4 4.8 2.8 9.3
256 KiB 10.7 7.2 3.8 3.8 21.1 12.5 12.8 8.7 6.8 18.9
512 KiB 66.9 23.4 7.4 7.4 44.8 24.4 25.0 16.8 13.1 39.9
1024 KiB 129.6 80.9 14.8 14.8 65.1 48.1 49.4 34.6 25.7 71.1
2048 KiB 352.8 208.2 99.0 100.1 104.3 159.9 158.5 136.9 122.5 91.0

(µs/op; py = this repo's protobuf-py Rust ext, py-gRPC = classic google.protobuf C/upb, Rust = prost, Rust-C = buffa (connect-rust), Go = protobuf-go. The two Rust codecs are on par on serialize; buffa wins deserialize (zero-copy views). Both drop sharply on 2 MiB serialize (~21 GB/s from ~70 GB/s).)

Unary throughput vs concurrency (1 KiB payload). The conns column is the fingerprint of each stack's concurrency mechanism — HTTP/1.1 has no multiplexing, so N in-flight requests require N sockets; the HTTP/2 clients each hold a single connection by construction (one channel — a client default, not a protocol cap). See the mechanics section below:

concurrency Connect req/s conns py-gRPC req/s conns Rust req/s conns Rust-Connect req/s conns Go req/s conns
1 4,871 1 4,446 1 20,898 1* 20,844 1* 19,583 1
4 11,658 4 13,563 1 36,672 1* 42,111 4* 36,698 1
16 12,769 16 27,139 1 40,516 1* 91,941 16* 63,796 1
64 13,383 64 27,950 1 40,497 1* 117,887 64* 72,394 1

(*Rust repos count via netstat, which shows each loopback connection from both endpoints and pool churn — Rust-gRPC's consistent "2" is one connection; Rust-Connect's counts track its HTTP/1.1 sockets (2 per connection) plus hyper pool churn. Both Go columns count via lsof/psutil, one row per connection.) Speedups vs c=1: Go 3.7×, py-gRPC 6.3×, Rust 1.9×, Connect 2.7×. Both Go and Rust are multi-threaded by default — Go schedules goroutines across GOMAXPROCS (= core count) OS threads, tonic runs on tokio's multi-threaded work-stealing runtime (workers = core count); neither is thread-per-core. Rust's low single-channel scaling is per-connection serialization, not CPU: spreading the same load over 8 tonic channels reaches ~134k req/s — see rust-grpc-demo and the chart below.)

Unary throughput vs concurrency

Large payloads & streaming:

Benchmark Connect (py) gRPC (py) gRPC (Rust) gRPC (Go) Connect h3 (Go) Connect (Rust)
unary, 2 MiB payload (c=8) 189 req/s (397 MB/s) 513 req/s (1,076 MB/s) 626 req/s (1,313 MB/s) 922 req/s (1,933 MB/s) 104 req/s (218 MB/s) 937 req/s (1,966 MB/s)
bidi streaming (1 KiB) 18.2k msg/s 13.7k msg/s 572k msg/s 398k msg/s 91k msg/s 90.5k msg/s

Large-payload throughput

Bidi streaming throughput

Takeaways:

  • Codec: prost (Rust) is the fastest at nearly every size — roughly 29× faster than the Python runtimes and 24× faster than protobuf-go. Deserialize is a wash between the two Python runtimes; classic protobuf (C/upb) serializes large messages ~1.52× faster than protobuf-py's Rust extension.
  • Compiled-language gRPC dominates throughput: Go peaks at 72k req/s on a single connection (3.7× scaling), Rust at 40k on a single channel (1.9×) — but ~134k req/s when spread over 8 channels, so Rust's plateau is per-connection serialization rather than language speed. Python gRPC reaches ~28k, Connect-over-HTTP/1.1 ~13k. All gRPC stacks hold one TCP connection per channel at every level; Connect's HTTP/1.1 needs one socket per in-flight request (see the mechanics table below).

Rust multi-channel scaling

  • The same protocol, implemented in Rust, is another 9× faster: Connect over HTTP/1.1 with connect-rust reaches 118k req/s at c=64 and ~2 GB/s at 2 MiB — the fastest stack in the family on large payloads — while still paying the HTTP/1.1 socket-per-request fingerprint.
  • Bidi streaming amplifies the language gap: Rust 572k msg/s and Go 398k msg/s vs ~1418k for both Python stacks (~3040×); Connect-rust at 90.5k msg/s sits with Go's HTTP/3 stack. Per-message overhead is dominated by the RPC stack, not the wire.

Numbers are machine- and load-dependent; treat them as indicative, not absolute.

Connect over HTTP/2

Yes — Connect is a protocol over HTTP, not tied to HTTP/1.1. server_h2.py serves the same ASGI application through Hypercorn with TLS/ALPN, and the client needs only pyqwest.HTTPTransport(http_version=HTTPVersion.HTTP2) (benchmark_h2.py shows how). Verified via ALPN negotiation: h2.

uv run server_h2.py      # terminal 1 (https://127.0.0.1:8443)
uv run benchmark_h2.py   # terminal 2

Results (same methodology as the HTTP/1.1 sweep):

concurrency req/s (HTTP/2, Hypercorn) TCP conns req/s (HTTP/1.1, uvicorn) TCP conns
1 2,616 1 4,871 1
4 5,502 1 11,658 4
16 7,188 1 12,769 16
64 7,058 1 13,383 64
Benchmark HTTP/2 (Hypercorn) HTTP/1.1 (uvicorn) gRPC (C core)
unary, 2 MiB payload (c=8) 81 req/s (170 MB/s) 189 req/s (397 MB/s) 513 req/s (1,076 MB/s)
bidi streaming (1 KiB) 11.2k msg/s 18.2k msg/s 13.7k msg/s

The mechanics change as theory predicts — one connection at every concurrency level — but throughput drops ~3550% versus HTTP/1.1 here. The reason is implementation, not protocol: Hypercorn's HTTP/2 stack is pure Python (h2 frame parsing per request), while uvicorn's HTTP/1.1 path is leaner, and gRPC's HTTP/2 lives in C. TLS handshake overhead adds a bit on top (amortized over the single connection, so it's minor).

Two practical notes discovered along the way:

  • Hypercorn closes a connection after keep_alive_max_requests = 1000 requests by default; server_h2.py disables that so sustained benchmarks don't race the server's connection close (they fail with "broken pipe" otherwise).
  • h2_max_concurrent_streams defaults to 100 — raised in server_h2.py so high concurrency isn't artificially capped.

The demo certs in certs/ are self-signed (CA + leaf, 30-day validity), generated with openssl; regenerate them if expired.

HTTP/1.1 vs HTTP/2 mechanics

The concurrency sweep surfaces the protocol difference that plain req/s numbers hide. Read the connection column as the fingerprint of each protocol's concurrency mechanism — not as a cost score:

  • HTTP/1.1 has no multiplexing. With pipelining effectively dead (no mainstream client enables it), one connection carries exactly one in-flight request — so N concurrent requests require N sockets. The parallel connections are not an anti-head-of-line-blocking optimization we chose; they are the only way to get concurrency at all.
  • Our HTTP/2 clients hold exactly one connection by construction: each benchmark drives a single channel/client, and one channel means one connection. That is a client default, not a protocol cap — HTTP/2 permits multiple connections (browsers open several per host, and gRPC deployments sometimes shard across channels on purpose).

The head-of-line-blocking tradeoff is likewise the opposite of the usual telling: with parallel HTTP/1.1 connections, each request owns its socket, so a lost packet stalls only that one request — the connection-per-request model eliminates cross-request HOL (the HOL problem HTTP/1.1 actually has is pipelining HOL, which nobody uses). HTTP/2's single shared connection buys multiplexing at the price of TCP-level HOL: one lost segment stalls every in-flight stream. Fixing that is precisely what QUIC/HTTP/3 exists for.

Mechanism Connect (pyqwest, HTTP/1.1) gRPC (HTTP/2)
Concurrency mechanism parallel connections (the only option: no multiplexing, pipelining unused) streams multiplexed over one connection
Connections at concurrency N N (measured: 1→1, 4→4, 16→16, 64→64) 1 by construction (single channel per client; the protocol allows more)
Scaling observed sockets scale linearly, throughput plateaus ~2.7× — bottleneck is Python per-request work, not sockets streams scale to ~6.3× (py), 3.7× (Go)
Head-of-line blocking none across requests — each request has its own socket; a lost packet stalls only its request TCP-level: one lost packet stalls every stream on the shared connection
Cost of the mechanism file descriptors, ephemeral ports, TLS handshakes (off-loopback), LB connection tracking shared flow-control window; single point of loss
Header encoding plain text on every request (no reuse) HPACK with shared dynamic table — repeated headers cost a few bytes
Streaming framing Connect envelope (5-byte prefix per message) inside a chunked HTTP/1.1 body length-prefixed frames inside HTTP/2 DATA frames
Server-side cost per call a fresh HTTP/1.1 request/response cycle in Python (uvicorn + connectrpc) stream handling in gRPC's C core, callbacks into Python

Practical implications:

  • HTTP/1.1's connection-per-request model means Connect's client opens (and keeps pooled — see the large-payload test note) one socket per unit of desired concurrency. That's nearly free on loopback — Connect's plateau is Python per-request work, not socket overhead — but a real cost at scale: file descriptors, ephemeral ports, server accept loops, and load balancer connection tracking.
  • HTTP/2's multiplexing makes concurrency "free" connection-wise, but concentrates risk: a dropped TCP segment stalls all in-flight streams (TCP head-of-line blocking), and both directions share one flow-control window. Workarounds (multiple channels/connections) reintroduce the connection management HTTP/2 was meant to remove.
  • Note that Connect is not wedded to HTTP/1.1 — see "Connect over HTTP/2" above: the same app multiplexes over one connection, but throughput depends on the HTTP implementation (pure-Python Hypercorn h2 is slower than uvicorn's h11, and both are slower than gRPC's C-core h2).
  • The next step on the protocol ladder is QUIC/HTTP/3, which removes the TCP head-of-line blocking above (per-stream loss recovery over UDP) — measured in golang-quic-rpc: Connect + quic-go holds one QUIC connection at every concurrency level, peaks at 38k req/s, but pays user-space packet costs on loopback (218 MB/s at 2 MiB payloads vs 1.9 GB/s for gRPC-Go over kernel TCP). That loopback picture is representative of perfect network conditions such as intra-datacenter calls, where HTTP/2 over kernel-optimized TCP will be the faster choice; under real (lossy, mobile, long-distance) network conditions QUIC's per-stream loss recovery avoids stalling every multiplexed stream on one lost segment, and it tends to come out ahead.

Type checking

The generated Connect code is fully typed — GreetServiceClient.greet takes and returns real message types, and the server implements the generated GreetService protocol. So static type checking works well here, using Astral's ty (a dev dependency):

uv run ty check

This checks server.py and client.py and the generated stubs in gen/. It's strict enough to catch real wiring mistakes — for example, it flags a service implementation whose method signature doesn't satisfy the generated GreetService protocol (missing parameters, wrong types, etc.) before you ever run the code.

How the code works

Schema (proto/greet/v1/greet.proto) — demonstrates all four RPC kinds:

service GreetService {
  // Unary: one request, one response.
  rpc Greet(GreetRequest) returns (GreetResponse);

  // Server streaming: one request, a stream of responses.
  rpc GreetStream(GreetStreamRequest) returns (stream GreetStreamResponse);

  // Client streaming: a stream of requests, one response.
  rpc GreetCount(stream GreetCountRequest) returns (GreetCountResponse);

  // Bidirectional streaming: streams both ways.
  rpc GreetChat(stream GreetChatRequest) returns (stream GreetChatResponse);
}

Note: Buf's STANDARD lint rules require each RPC to have dedicated request / response messages (hence GreetStreamRequest etc. rather than reusing GreetRequest).

Server (server.py): implements all four methods and wraps them in the generated GreetServiceASGIApplication, served by uvicorn on 127.0.0.1:8080.

  • greet — plain async method: async def greet(request, ctx) -> GreetResponse
  • greet_stream — async generator yielding multiple responses
  • greet_count — takes an AsyncIterator of requests, returns one response
  • greet_chat — async generator consuming and yielding a stream

Client (client.py): instantiates the generated GreetServiceClient with just the base URL (http://127.0.0.1:8080 — the protocol appends the full procedure path itself) and runs one demo per RPC mode:

--- unary: Greet ---
[client] Hello, world!

--- server streaming: GreetStream ---
[client] Hello #1, streamer!
[client] Hello #2, streamer!
[client] Hello #3, streamer!

--- client streaming: GreetCount ---
[client] server counted 3 requests

--- bidirectional streaming: GreetChat ---
[client] Hi, alice!
[client] Hi, bob!
[client] Hi, carol!

Because unary Connect is plain HTTP with a JSON option, this works too:

curl -X POST -H 'content-type: application/json' \
  -d '{"name": "world"}' \
  http://127.0.0.1:8080/greet.v1.GreetService/Greet
# {"greeting":"Hello, world!"}

(The Connect protocol supports both binary protobuf and JSON bodies on unary calls — the server negotiates via the request's Content-Type, so the same endpoint serves both. Streaming RPCs use a length-prefixed envelope over the HTTP body, so those are protobuf-only in practice.)