Skip to content

PLT-1072: Wire rate limiter into native gRPC (:9090) unary+stream interceptors - #4021

Open
amir-deris wants to merge 1 commit into
mainfrom
amir/plt-1072-rate-limiter-grpc-interceptors
Open

PLT-1072: Wire rate limiter into native gRPC (:9090) unary+stream interceptors#4021
amir-deris wants to merge 1 commit into
mainfrom
amir/plt-1072-rate-limiter-grpc-interceptors

Conversation

@amir-deris

@amir-deris amir-deris commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Wires the shared ratelimiter.Registry (from PLT-411 / PLT-800) into native gRPC on :9090 via unary and stream server interceptors. Rejections return codes.ResourceExhausted and emit rpc_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.

  • Interceptors (sei-cosmos/server/grpc/rate_limit.go) — per-IP token bucket at unary call and stream establishment; uses Registry.IPFromGRPCContext and info.FullMethod (no MethodParser / body pre-read needed on gRPC).
  • Server wiring (StartGRPCServer) — interceptors chained at server creation, before BaseApp query handler registration, so admission runs before sdk.Context creation.
  • Config ([grpc] in app.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).
  • Metrics bucketing (ratelimiter/method_bucket.go) — adds PlaneGRPC and 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/...
  • Unary interceptor: allow under limit, burst then ResourceExhausted, per-IP isolation, trusted-proxy XFF
  • Stream interceptor: allow then reject at stream open
  • Config: defaults, absent-key reads, manifest/golden updates for new [grpc] keys
  • Manual: enable grpc.rate-limiting-enabled = true on a dev node and confirm 429-equivalent gRPC rejections under burst load

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>
@amir-deris amir-deris self-assigned this Aug 26, 2026
@amir-deris amir-deris changed the title PLT-1072: Wire rate limiter into gRPC unary+stream interceptors (:9090) PLT-1072: Wire rate limiter into native gRPC (:9090) unary+stream interceptors Aug 26, 2026
@amir-deris
amir-deris marked this pull request as ready for review August 26, 2026 15:41
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes the public gRPC query path when operators enable rate limiting; misconfigured trusted-proxy CIDRs or limits could block legitimate clients or rate-limit on the wrong IP.

Overview
Adds optional per-IP token-bucket rate limiting on native gRPC (:9090) by chaining unary and stream server interceptors when grpc.rate-limiting-enabled is true. Over-limit calls return ResourceExhausted ("too many requests") via the shared ratelimiter.Registry, using peer/x-forwarded-for (with trusted-proxy-cidrs) and info.FullMethod for admission.

[grpc] in app.toml gains rate-limiting-enabled (default off), ip-rate-limit-rps / ip-rate-limit-burst (defaults from ratelimiter, guarded on absent keys), and trusted-proxy-cidrs. GRPCConfig.RateLimiterConfig() maps these into the registry; StartGRPCServer only registers interceptors when the master switch is on.

Metrics: PlaneGRPC and gRPC service-name bucketing (known protobuf services → low-cardinality labels; unknown paths → other) so rejection metrics stay bounded.

Reviewed by Cursor Bugbot for commit ed71a10. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ed71a10. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/StreamServerInterceptor closures 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 real grpc.Server via StartGRPCServer (or at minimum registers a service through BaseApp.RegisterGRPCServer), dials it, and asserts codes.ResourceExhausted after 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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant