grpc interceptors, and channel-wide credentials - #639
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
net.grpcexposedretryable_codeand nothing else, whilestd.webalready had a middleware chain withrate_limitandcircuitready 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:
interceptreturns a dispatch, which is whatservealready takes, so no serve form changes signature and a generatedserve_<Svc>router composes exactly like a hand-written one. theChainstruct 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, andbearer_token()reads the credential out of anauthorizationheader with the case-insensitive scheme match rfc 7235 asks for.rate_limitandcircuittake the samestd.resilienceLimiterandBreakerthe web middlewares take, so one limiter can cap an http surface and a grpc surface together.on the client,
set_credentialsattaches metadata to the channel instead of to every call site.three decisions worth a reviewer's attention
circuitcounts 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_credentialsmutates instead of returning a configured copy. twoConnvalues 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.refuse(status, message), not by failing. a pith lambda can neitherfailnor use!— found by probe before the api was written, and it is what shapes the ready-made interceptors.what was tested
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 newexamples/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.notes
docs/grpc.mdgets 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 byintercept. the observability entry is updated too, since an interceptor is now the place to instrument a server.pith buildon a missing file reports "this is a compiler bug, not a problem in your program", andpith fmtrewritesreturn -1intoreturn - 1instd/net/grpc.pith. both predate this change, and ci only format-checksself-host/, so neither gates.