Chapter 18: Typed gRPC Mocks — Stub Production gRPC Services in Starlark
Duration: 20 minutes
Prerequisites: Chapter 17 (Mock Services), a working protoc and some familiarity with .proto files
Goals & Purpose
Your service talks to a handful of gRPC dependencies — geo-config,
user-service, balance-api — all via compiled *.pb.go stubs. You want
to run it under Faultbox without those real services running.
Chapter 17’s generic mock_service() gets you 90% of the way, but the
last 10% bites: the native mock encodes responses as
google.protobuf.Struct because it has no schema for your services.
Typed Go clients reject Struct payloads at decode time —
pkg.NewClient(conn).GetCity(ctx, req) either fails or silently
returns zero values.
Typed gRPC mocks (shipped in v0.9.0) close that gap. You hand
Faultbox a FileDescriptorSet — a compact binary that protoc emits
from your .proto files — and the mock encodes responses as real
message types on the wire. Your compiled-stub client decodes them
normally, as if it were talking to the real upstream.
This chapter teaches you to:
- Build a
FileDescriptorSet(.pbfile) from your existing.protofiles usingprotoc. - Load it into a
grpc.server()via thedescriptors=kwarg. - Declare typed responses as Starlark dicts that Faultbox encodes against the right message type at request time.
- Debug with grpcurl — typed mocks auto-register the standard gRPC reflection service.
- Compose typed mocks with real services and fault rules in the same topology.
After this chapter, you can swap a real gRPC upstream for a typed mock
with one .pb file and a page of Starlark — no Go binary, no
Dockerfile.
Why descriptor sets, not .proto files
Faultbox deliberately does not ship protoc and does not parse
.proto files directly. Reason: customers already have proto build
pipelines. A monorepo pattern — proto source and pre-built .pb.go
in a dedicated repository — is the norm. Faultbox plugs into that
pipeline by consuming the output, not by trying to own the build.
Generate the .pb file once:
protoc \
--include_imports \
--descriptor_set_out=./proto/upstreams.pb \
proto/yourcorp/geo_config/*.proto \
proto/yourcorp/user/*.proto
--descriptor_set_out=<path>— output the compiled descriptors as a single.pbfile (binary-encodedgoogle.protobuf.FileDescriptorSet).--include_imports— include transitive.protodependencies so the set is self-contained.
The standard google.protobuf.* well-known types (Timestamp,
Empty, Any, Struct, Duration, FieldMask, wrappers) are
pre-registered by Faultbox — you don’t need to include them in your
.pb, and your customer protos that import them resolve automatically.
A first typed mock
Suppose your SUT calls /yourcorp.geo.GeoService/GetCity and expects
a typed yourcorp.geo.City back.
# faultbox.star
load("@faultbox/mocks/grpc.star", "grpc")
geo = grpc.server(
name = "geo",
interface = interface("main", "grpc", 9001),
descriptors = "./proto/upstreams.pb",
services = {
"/yourcorp.geo.GeoService/GetCity": {
"response": {
"id": 42,
"name": "Almaty",
"country": "KZ",
"currency": "KZT",
},
},
},
)
api = service("api",
binary = "./bin/api",
interface("public", "http", 8080),
env = {
"GEO_GRPC_ADDR": geo.main.internal_addr,
},
depends_on = [geo],
healthcheck = http("localhost:8080/health"),
)
def test_city_lookup():
r = http.get(api.public, "/cities/42")
assert_eq(r.status, 200)
assert_eq(r.json.name, "Almaty")
Run it:
$ faultbox test ./faultbox.star --test city_lookup
Under the hood: when the api calls GetCity, Faultbox looks up the
method’s output type in your .pb (yourcorp.geo.City), encodes
the Starlark dict as a typed City on the wire, and the api’s
compiled *.pb.go stub decodes it normally. Zero difference from a
real upstream on the decode side.
Response shapes
Each entry in the services={...} dict is one of four shapes:
{"response": <dict>} — happy-path typed response
The dict is encoded against the method’s output descriptor at request
time. Unknown fields surface as errors (unknown field "cityid" (did you mean "city_id"?)) — you’ll see them the first time the SUT
hits the route.
"/yourcorp.geo.GeoService/GetCity": {
"response": {"id": 42, "name": "Almaty"},
},
{"error": {"code": "X", "message": "..."}} — status-code error
The mock returns a gRPC error with the specified status code.
code is either a canonical name ("UNAVAILABLE",
"PERMISSION_DENIED", etc.) or an integer (1–16).
"/yourcorp.geo.GeoService/AdminUpdate": {
"error": {"code": "PERMISSION_DENIED", "message": "admin only"},
},
grpc.dynamic(fn) — per-request Starlark handler
When canned responses aren’t enough, a Starlark function receives the request and returns a response:
def handle_by_coords(req):
# req.body is the decoded request dict; for typed mocks, not yet
# available in v0.9.0 (JSON-only). Use routes + response for most
# cases; dynamic for pure-response logic.
return grpc.response({
"id": 1 if req.body else 2,
"name": "dynamic",
})
"/yourcorp.geo.GeoService/GetCityByCoords": grpc.dynamic(handle_by_coords),
grpc.raw_response(bytes) — pre-encoded wire bytes escape hatch
For cases the typed encoder can’t express — oneof tricks, deprecated fields, extensions — pass the exact wire bytes:
"/yourcorp.geo.GeoService/Exotic": grpc.raw_response(b"\x08\x2a"),
Multi-service mock processes
When several gRPC services share a single mock process on different
ports — the truck-api pattern — declare one grpc.server() per service,
all sharing the same .pb file:
descriptors = "./proto/upstreams.pb"
geo = grpc.server(
name = "geo",
interface = interface("main", "grpc", 9001),
descriptors = descriptors,
services = { "/yourcorp.geo.GeoService/GetCity": {...} },
)
user = grpc.server(
name = "user",
interface = interface("main", "grpc", 9003),
descriptors = descriptors,
services = { "/yourcorp.user.UserService/GetUser": {...} },
)
api = service("api",
binary = "./bin/api",
interface("public", "http", 8080),
env = {
"GEO_GRPC_ADDR": geo.main.internal_addr,
"USER_GRPC_ADDR": user.main.internal_addr,
},
depends_on = [geo, user],
)
Each mock is an independent Faultbox service, so fault rules target them one at a time without leaking.
Faulting a typed mock
@faultbox/recipes/grpc.star fault recipes work unchanged — the
fault layer sits in front of the typed encoder:
load("@faultbox/recipes/grpc.star", "grpc_faults")
geo_down = fault_assumption("geo_down",
target = geo.main,
rules = [grpc_faults.unavailable()],
)
test_retries_on_geo_down = fault_scenario("retries_on_geo_down",
scenario = create_order,
faults = [geo_down],
expect = lambda r: assert_eq(r.status, 200), # retry succeeds
)
The fault fires at the proxy layer; the typed encoder never runs on the faulted request.
grpcurl support
Typed mocks automatically register the standard gRPC reflection v1
service. Point grpcurl at the mock and it discovers everything:
$ grpcurl -plaintext localhost:9001 list
grpc.reflection.v1.ServerReflection
yourcorp.geo.GeoService
$ grpcurl -plaintext localhost:9001 describe yourcorp.geo.GeoService
yourcorp.geo.GeoService is a service:
service GeoService {
rpc GetCity(GetCityRequest) returns (City);
}
$ grpcurl -plaintext -d '{"id":1}' localhost:9001 yourcorp.geo.GeoService/GetCity
{
"id": "42",
"name": "Almaty",
"country": "KZ",
"currency": "KZT"
}
Useful for debugging when your mock’s behavior doesn’t match what the SUT expects — you can exercise the mock directly without running the SUT.
Reflection is only registered when descriptors= is set; untyped
mock_service() gRPC mocks don’t auto-register to avoid surprises.
What’s not in v1
RFC-023 scoped v0.9.0 to the 90% case. Deferred:
- Streaming RPCs — unary only. Server-streaming, client-streaming, and bidi are separate design problems; revisit when a real customer needs them.
- Custom error details (
google.rpc.Statuswith typeddetails). Plain status-code errors only. - Load-time response-shape validation. A typo like
"cityid"instead of"city_id"surfaces at request time with a clear error message. Load-time is a v2 ergonomic win. - Raw
.protoingestion. You generate the.pbviaprotoc; Faultbox consumes it. Parsing.protodirectly would re-import the complexity we delegated to your build system. - Connect gRPC-Web and pure Connect protocol. Standard gRPC and
connect-gowithconnect.WithGRPC()both work; other Connect flavors need a separate handler.
When to use this vs. mock_service()
- Your SUT uses compiled Go/C++/Rust gRPC stubs → typed gRPC mock.
The generic
Structpayload won’t decode. - Your SUT uses reflection-based clients (Node, some Python
setups) → either works.
mock_service()is simpler because it skips the.pbstep. - You’re testing a feature flag fetch, OIDC JWKS, or other
not-really-gRPC-but-HTTP endpoint → use Chapter 17’s
mock_service()for HTTP. Don’t reach for gRPC if you don’t need it.
When to drop to a Go binary
Typed Starlark mocks don’t cover every case. If you need:
- Streaming RPCs (server/client/bidi).
- Complex server-side logic that
grpc.dynamic()can’t express (stateful accumulation across calls, deep field inspection of the request, etc.). - Custom TLS client-cert handling.
- A protocol flavor Faultbox doesn’t speak natively.
… drop to a Go binary that imports your real *.pb.go. See the
service(binary=...) pattern in
mock-services.md.
For everything else in v0.9.0, start with grpc.server().
Summary
grpc.server(descriptors="./x.pb", services={...})is the primary way to mock gRPC upstreams with typed responses.- Generate
.pbviaprotoc --descriptor_set_out=...— Faultbox doesn’t wrap protoc; your existing build owns that. - Four response shapes:
{"response": dict},{"error": {...}},grpc.dynamic(fn),grpc.raw_response(bytes). - Reflection auto-registers — grpcurl works out of the box.
- Fault recipes compose unchanged — fault the typed mock as if it were a real upstream.
- RFC-023 scope is the 90% case. Streaming, rich errors, and alternate Connect flavors are explicit v2 candidates.