PLT-1072: Wire rate limiter into native gRPC (:9090) unary+stream interceptors - #4021
PLT-1072: Wire rate limiter into native gRPC (:9090) unary+stream interceptors#4021amir-deris wants to merge 1 commit into
Conversation
Applied per-IP token-bucket admission on native gRPC
before handlers run, emitting rpc_rate_limit_rejected_total{plane="grpc"}.
Co-authored-by: Cursor <cursoragent@cursor.com>
PR SummaryMedium Risk Overview
Metrics: Reviewed by Cursor Bugbot for commit ed71a10. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ed71a10. Configure here.
| ) | ||
| } | ||
|
|
||
| grpcSrv := grpc.NewServer(serverOpts...) |
There was a problem hiding this comment.
Unary rate limiter skipped for queries
High Severity
The unary interceptor is installed as a server option, but RegisterGRPCServer replaces each method handler and discards the interceptor gRPC passes in. In grpc-go v1.57.1 that argument is s.opts.unaryInt, so per-IP admission never runs for module Query and Tx unary RPCs — almost all of :9090. Isolated interceptor tests do not exercise this registration path.
Reviewed by Cursor Bugbot for commit ed71a10. Configure here.
There was a problem hiding this comment.
The config plumbing, metrics bucketing, and characterization-suite updates are solid, but the unary rate-limit interceptor is effectively dead code: BaseApp's RegisterGRPCServer wrapper discards the interceptor grpc-go hands to each MethodDesc.Handler, so no module Query service on :9090 is actually rate limited.
Findings: 1 blocking | 2 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
- 1 blocking issue(s) flagged inline on specific lines.
Non-blocking
- [suggestion] All interceptor tests invoke the returned
grpc.UnaryServerInterceptor/StreamServerInterceptorclosures directly, so none of them exercise grpc-go's dispatch path. That is exactly why the BaseApp bypass is invisible to the suite. Add a test that starts a realgrpc.ServerviaStartGRPCServer(or at minimum registers a service throughBaseApp.RegisterGRPCServer), dials it, and assertscodes.ResourceExhaustedafter the burst is spent. - 1 suggestion(s)/nit(s) flagged inline on specific lines.
| return nil, fmt.Errorf("grpc rate limiter: %w", err) | ||
| } | ||
| serverOpts = append(serverOpts, | ||
| grpc.ChainUnaryInterceptor(UnaryRateLimitInterceptor(rateLimitRegistry)), |
There was a problem hiding this comment.
[blocker] grpc.ChainUnaryInterceptor does not reach the services this server actually exposes.
grpc-go does not invoke s.opts.unaryInt itself for unary RPCs — it passes it as the fourth argument to MethodDesc.Handler and relies on the generated handler to call it. BaseApp.RegisterGRPCServer rebuilds every grpc.MethodDesc with a handler that discards that argument (sei-cosmos/baseapp/grpcserver.go:81: _ grpc.UnaryServerInterceptor) and substitutes its own ChainUnaryServer(grpcrecovery..., interceptor) chain.
Every service registered through app.RegisterGRPCServer(grpcSrv) on line 58 comes from GRPCQueryRouter().serviceData — all module Query services, plus cosmos.tx.v1beta1.Service and cosmos.base.tendermint.v1beta1.Service (app/app.go:2581-2582). None of them ever call UnaryRateLimitInterceptor. The only unary service that does is cosmos.base.reflection.v2alpha1.ReflectionService, registered directly on line 61.
The stream interceptor is fine — processStreamingRPC calls s.opts.streamInt directly and newDesc copies desc.Streams verbatim — but the gRPC query surface is entirely unary, so with rate-limiting-enabled = true the node is still unprotected.
The choke point every unary call passes through is BaseApp's wrapper handler, so the fix belongs there: thread the incoming interceptor into the chain (e.g. ChainUnaryServer(recovery, incoming, interceptor) with a nil guard) rather than dropping it, so any server-level interceptor — this one and future ones — applies without each registration site remembering to re-add it.
| } | ||
|
|
||
| // grpcKeys covers the three [grpc] keys read as plain casts. | ||
| // grpcKeys covers the four [grpc] keys read as plain casts. |
There was a problem hiding this comment.
[suggestion] The summary line was updated to "four" but the body below it still carries the old counts: line 1363-1364 says "only three keys are rows. Eight others are read behind v.IsSet", which is now four rows and eleven others. grpcKeysWithTargetsOfTheirOwn's godoc (line 1398) has the same drift — "Six are read behind v.IsSet ... The other two" is now nine and two, since ip-rate-limit-rps, ip-rate-limit-burst, and trusted-proxy-cidrs are all v.IsSet-guarded.
This suite is the repo's record of how configuration resolves, and its counts are what a reader checks the tables against, so the stale numbers actively mislead.


Summary
Wires the shared
ratelimiter.Registry(from PLT-411 / PLT-800) into native gRPC on:9090via unary and stream server interceptors. Rejections returncodes.ResourceExhaustedand emitrpc_rate_limit_rejected_total{plane="grpc", method_namespace="..."}.This is the last Phase 1 plane; EVM HTTP (PLT-819), CometBFT HTTP (PLT-981), and the core registry/parser are already landed.
sei-cosmos/server/grpc/rate_limit.go) — per-IP token bucket at unary call and stream establishment; usesRegistry.IPFromGRPCContextandinfo.FullMethod(noMethodParser/ body pre-read needed on gRPC).StartGRPCServer) — interceptors chained at server creation, before BaseApp query handler registration, so admission runs beforesdk.Contextcreation.[grpc]inapp.toml) —rate_limiting_enabled,ip_rate_limit_rps,ip_rate_limit_burst,trusted_proxy_cidrs; ships disabled by default (same rollout pattern as 1b/1c).ratelimiter/method_bucket.go) — addsPlaneGRPCand low-cardinality service-name labels for known protobuf services.Out of scope: gRPC-Web (
:9091, Phase 4d).Test plan
go test ./ratelimiter/... ./sei-cosmos/server/grpc/... ./sei-cosmos/server/config/...ResourceExhausted, per-IP isolation, trusted-proxy XFF[grpc]keysgrpc.rate-limiting-enabled = trueon a dev node and confirm 429-equivalent gRPC rejections under burst load