Skip to content

grpc interceptors, and channel-wide credentials - #639

Merged
kacy merged 1 commit into
mainfrom
grpc-interceptors
Aug 2, 2026
Merged

grpc interceptors, and channel-wide credentials#639
kacy merged 1 commit into
mainfrom
grpc-interceptors

Conversation

@kacy

@kacy kacy commented Aug 2, 2026

Copy link
Copy Markdown
Owner

net.grpc exposed retryable_code and nothing else, while std.web already had a middleware chain with rate_limit and circuit ready made. that left no place to put auth on a grpc transport short of writing the check into the top of every method.

an interceptor now wraps a dispatch the way a web middleware wraps a route handler — it takes the rest of the chain plus the call, so it can run code before or after it, or answer the call itself and never reach the dispatch:

guarded := grpc.intercept(serve_Chat, [grpc.authorize(check_token), grpc.rate_limit(limiter)])
grpc.serve("0.0.0.0", 50051, guarded)!

intercept returns a dispatch, which is what serve already takes, so no serve form changes signature and a generated serve_<Svc> router composes exactly like a hand-written one. the Chain struct is the same shape web's uses and exists for the same reason: a closure capturing a bare fn-value miscompiles, one capturing a struct whose fields it calls in place does not.

three come ready made. authorize(verify) is the hook the gap was really about — it runs against the caller's metadata before any method body, so one interceptor covers every rpc a server exposes, and bearer_token() reads the credential out of an authorization header with the case-insensitive scheme match rfc 7235 asks for. rate_limit and circuit take the same std.resilience Limiter and Breaker the web middlewares take, so one limiter can cap an http surface and a grpc surface together.

on the client, set_credentials attaches metadata to the channel instead of to every call site.

three decisions worth a reviewer's attention

  • circuit counts only the server's own faults — INTERNAL, UNAVAILABLE, DATA_LOSS, an expired deadline. a caller's error (NOT_FOUND, an invalid argument, a refused credential) does not trip it, because a client sending bad requests is not a reason to stop serving good ones. there is a test pinning that both ways.
  • set_credentials mutates instead of returning a configured copy. two Conn values sharing one http/2 client would each close it. per-call metadata is appended after the channel's, so a call can add to the credentials but not silently drop them.
  • an interceptor refuses by returning refuse(status, message), not by failing. a pith lambda can neither fail nor use ! — found by probe before the api was written, and it is what shapes the ready-made interceptors.

what was tested

  • 8 new unit tests in std/net/grpc.pith (47 total, 0 failed): composition order (first registered runs outermost), an empty list being a no-op, refusal short-circuiting before the dispatch, a dispatch error propagating out through the chain, each ready-made interceptor, and the bearer-token parse including the case-insensitive scheme and the non-bearer and truncated headers.
  • tests/cases/test_grpc_interceptors.pith — a live round trip over h2c: the same server refuses an anonymous call with UNAUTHENTICATED and serves a credentialed one. this is the part the unit tests cannot reach, since it proves the interceptor sees metadata the client actually put on the wire and that its refusal comes back as a grpc-status.
  • make run-regressions-only: 304 passed, 0 failed.
  • make run-examples: 105 passed, 0 failed, including the new examples/grpc_interceptors.pith — a service that knows nothing about auth or rate limiting, guarded by both plus a call counter, called by a client that presents its credential once. its rate limiter uses a slow refill so the refused call cannot race a token trickling back in; checked deterministic across three runs.
  • doc coverage and lint clean on the new surface.

notes

  • docs/grpc.md gets an interceptors section, and its "what isn't here yet" list gains an honest entry: interceptors are server-side and unary — the client has channel credentials but no chain of its own, and the streaming serve forms take their own dispatch shapes and are not wrapped by intercept. the observability entry is updated too, since an interceptor is now the place to instrument a server.
  • two incidental findings, neither addressed here: pith build on a missing file reports "this is a compiler bug, not a problem in your program", and pith fmt rewrites return -1 into return - 1 in std/net/grpc.pith. both predate this change, and ci only format-checks self-host/, so neither gates.

net.grpc had retryable_code and nothing else, while std.web had a
middleware chain with rate_limit and circuit ready made — so there was
nowhere to put auth on a grpc transport short of writing the check into
every method.

an interceptor now wraps a dispatch the way a web middleware wraps a route
handler: it takes the rest of the chain plus the call, so it can run code
before or after, or answer the call and never reach the dispatch.
intercept() folds a list of them into a dispatch, which is what serve()
already takes, so no serve form changes signature and a generated
serve_<Svc> router composes exactly like a hand-written one. the Chain
struct is the same shape web's uses, and for the same reason: a closure
capturing a bare fn-value miscompiles, one capturing a struct it calls in
place does not.

authorize() is the hook the gap was really about — it runs against the
caller's metadata before any method body, so one interceptor covers every
rpc a server exposes, and bearer_token() reads the credential out of an
authorization header with the case-insensitive scheme match rfc 7235
asks for. rate_limit() and circuit() take the same std.resilience Limiter
and Breaker the web middlewares take, so one limiter can cap an http and a
grpc surface together. circuit() counts only the server's own faults:
a client sending bad requests is not a reason to stop serving good ones.

on the client, set_credentials() attaches metadata to a channel instead of
to every call site. it mutates rather than returning a configured copy —
two Conn values sharing one http/2 client would each close it — and
per-call metadata is appended after it, so a call can add to the
credentials but not silently drop them.

an interceptor refuses a call by returning refuse(status, message) rather
than failing: a pith lambda can neither fail nor use !, which is why the
ready-made ones are shaped the way they are.

verified: 8 unit tests over composition order, refusal, error propagation,
and each ready-made interceptor; a round-trip case where a real client is
refused UNAUTHENTICATED without a credential and served with one; and a
runnable example putting auth, a cap, and a counter over a service that
knows about none of them.
@kacy
kacy merged commit 6bcaa1a into main Aug 2, 2026
2 checks passed
@kacy
kacy deleted the grpc-interceptors branch August 2, 2026 23:45
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.

1 participant