Skip to content

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

Open
amir-deris wants to merge 5 commits 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 5 commits 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
When enabled, all native gRPC query traffic is gated on per-IP admission and proxy/XFF trust settings; misconfigured trusted CIDRs could rate-limit the wrong clients, though the master switch defaults off.

Overview
Adds optional per-IP token-bucket rate limiting on native gRPC (:9090) when grpc.rate-limiting-enabled is true. Over-limit unary and stream calls return ResourceExhausted before handlers run; limits use ip-rate-limit-rps, ip-rate-limit-burst, and optional trusted-proxy-cidrs for client IP via x-forwarded-for. Disabled by default so existing nodes are unchanged until operators opt in.

StartGRPCServer chains new unary/stream interceptors backed by the shared ratelimiter.Registry. RegisterGRPCServer now honors server-level interceptors via chainQueryInterceptors (recovery → server interceptors → query context) so rate-limit rejection does not allocate query context.

Metrics gain PlaneGRPC and bounded service-name labels for known protobuf services on rejection metrics. Config, app.toml template, cosmosbase key tests, and fuzz/golden manifests are updated for the four new [grpc] keys.

Reviewed by Cursor Bugbot for commit e633f59. 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.

Comment thread sei-cosmos/server/grpc/server.go
seidroid[bot]
seidroid Bot previously requested changes Aug 26, 2026

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

Comment thread sei-cosmos/server/grpc/server.go
Comment thread sei-cosmos/server/config/config_fuzz_test.go
amir-deris and others added 2 commits August 28, 2026 15:32
BaseApp.RegisterGRPCServer rebuilt every grpc.MethodDesc with a handler
that discarded the grpc.UnaryServerInterceptor grpc-go passes in. Since
grpc-go hands s.opts.unaryInt to MethodDesc.Handler rather than invoking
it itself, every service registered through RegisterGRPCServer -- all
module Query services, cosmos.tx.v1beta1.Service and
cosmos.base.tendermint.v1beta1.Service -- ran no server-level
interceptor. The rate limiter wired onto :9090 therefore covered only
the directly-registered reflection service.

Thread the incoming interceptor into the chain at that wrapper handler,
the single point every unary query passes through, so any
grpc.ChainUnaryInterceptor applies without each registration site
re-adding it. The query-context interceptor runs last, so admission
control rejects a call before it costs a query context.

Add tests that register through RegisterGRPCServer on a real grpc.Server
and dial it, covering both the generic interceptor contract and the rate
limiter reaching ResourceExhausted once a burst is spent. The existing
interceptor tests invoked the closures directly, which is why the bypass
was invisible.

Also correct the stale [grpc] key counts and clamp line reference in the
configuration characterization suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 28, 2026, 2:36 PM

seidroid[bot]
seidroid Bot previously requested changes Aug 28, 2026

@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 interceptor wiring and the BaseApp dispatch fix from the previous round look correct and are now covered by real-server regression tests. However, the four new [grpc] config fields are auto-derived into the config/registry key space, and config/cosmosbase was not updated to match, which breaks two characterization tests on the base branch.

Findings: 1 blocking | 5 non-blocking | 5 posted inline

Blockers

  • config/cosmosbase was not updated for the four new [grpc] keys, and two of its tests will fail. registry.RegisterSection(GRPCSectionName, &srvconfig.GRPCConfig{}, grpcDefaults) (config/cosmosbase/cosmosbase.go:40) derives the section's declared keys from GRPCConfig's mapstructure tags (config/registry/registry.go:144deriveKeys/walk), so adding ip-rate-limit-rps, ip-rate-limit-burst, rate-limiting-enabled, and trusted-proxy-cidrs takes the registered set from 11 to 15 keys automatically. Two tests still hold the old set: (1) TestTheGRPCKeysAreTheOnesItsReaderResolves (config/cosmosbase/cosmosbase_test.go:75-81) does reflect.DeepEqual against a hard-coded 11-key list and will fail; (2) TestEveryKeyTheseSectionsDeclareIsOneTheReaderResolves (config/cosmosbase/agreement_test.go:184) walks every registered key and errors for each one absent from readerValues, which still stops at grpc.keepalive-permit-without-stream (agreement_test.go:102-112) — four errors. Fix: add the four keys to the requireDeclares list and add the four cfg.GRPC.* entries to readerValues. While there, the grpcDefaults godoc (cosmosbase.go:99) still reads "Six of these eleven keys are read only when the key is present"; it is now nine of fifteen, and per AGENTS.md that count is part of the record rather than incidental prose.

Non-blocking

  • None at the file/PR level.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread ratelimiter/method_bucket.go
Comment thread sei-cosmos/server/grpc/server.go
Comment thread sei-cosmos/server/config/config_fuzz_test.go
Comment thread sei-cosmos/server/config/config_fuzz_test.go Outdated
Comment thread sei-cosmos/server/grpc/rate_limit.go
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.69014% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.30%. Comparing base (5ef8bb2) to head (e633f59).

Files with missing lines Patch % Lines
sei-cosmos/server/grpc/server.go 20.00% 7 Missing and 1 partial ⚠️
sei-cosmos/server/config/config.go 77.27% 5 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4021      +/-   ##
==========================================
- Coverage   61.28%   60.30%   -0.99%     
==========================================
  Files        2154     2056      -98     
  Lines      188439   176796   -11643     
==========================================
- Hits       115491   106614    -8877     
+ Misses      62219    60419    -1800     
+ Partials    10729     9763     -966     
Flag Coverage Δ
sei-chain-pr 75.60% <81.69%> (?)
sei-db 69.80% <ø> (-0.22%) ⬇️
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
config/cosmosbase/cosmosbase.go 100.00% <ø> (ø)
ratelimiter/method_bucket.go 100.00% <100.00%> (ø)
sei-cosmos/baseapp/grpcserver.go 75.51% <100.00%> (+2.17%) ⬆️
sei-cosmos/server/config/toml.go 57.14% <ø> (ø)
sei-cosmos/server/grpc/rate_limit.go 100.00% <100.00%> (ø)
sei-cosmos/server/config/config.go 96.27% <77.27%> (-1.54%) ⬇️
sei-cosmos/server/grpc/server.go 63.63% <20.00%> (-10.84%) ⬇️

... and 100 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Register the four new [grpc] keys in config/cosmosbase so characterization
tests stay in sync, fix config-test doc/grouping drift, and correct
knownGRPCServices for reflection metrics.

Co-authored-by: Cursor <cursoragent@cursor.com>
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

@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 previous round's blocker is genuinely fixed: the BaseApp dispatch bypass is closed at the one wrapper handler every unary query passes through, real-server regression tests cover it, and the four new [grpc] keys are now declared in config/cosmosbase with consistent counts and a corrected knownGRPCServices list. Two suggestions from the last review remain open (no operator signal when admission is enabled with a zeroed bucket, and the unstated per-stream admission scope), and gRPC-Web silently shares these interceptors despite being listed as out of scope.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] gRPC-Web is not actually out of scope: StartGRPCWeb wraps this same *grpc.Server (sei-cosmos/server/grpc/grpc_web.go:29), so both interceptors also govern :9091, and its traffic draws tokens from the same per-IP buckets as :9090. grpc-go's handler-server transport does set the peer from req.RemoteAddr, so per-IP attribution works there, but it means [grpc] trusted-proxy-cidrs also decides whose x-forwarded-for is honoured on the browser-facing port. Worth confirming that is intended and saying so on the config keys rather than leaving the PR description's "Out of scope: gRPC-Web" to imply :9091 is untouched.
  • [suggestion] Nothing exercises StartGRPCServer's enable path. rate_limit_dispatch_test.go builds its own grpc.NewServer(grpc.ChainUnaryInterceptor(...)), and no test calls StartGRPCServer with RateLimitingEnabled = true, so deleting the two serverOpts appends — or the ratelimiter.New error return for a malformed CIDR — leaves the suite green. That is the same class of gap that hid last round's BaseApp bypass, one layer up.
  • [suggestion] The three v.IsSet-guarded keys are recorded only by name and for their absent-key resolution; no test reads a value that is actually present. The six pre-existing guarded [grpc] keys are covered on that path by TestGetConfigGRPCOverrides (config_test.go:167-190), which was not extended. A typo repeated in both the IsSet call and the getter (e.g. grpc.ip-rate-limit-rp in both) would pass every test in the suite today. Adding ip-rate-limit-rps, ip-rate-limit-burst and trusted-proxy-cidrs to that test closes it.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

}),
)
}
if cfg.RateLimitingEnabled {

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] Still no operator signal when admission is enabled but the bucket is off. Registry.Allow returns true unconditionally when RPS <= 0 || Burst <= 0 (ratelimiter/registry.go:93), so rate-limiting-enabled = true with either bucket key at zero or negative gives an unprotected :9090 with the interceptors installed and throttling nothing — silently.

The CometBFT plane logs for exactly this combination (sei-tendermint/internal/rpc/core/env.go:352-359, and again in internal/inspect/rpc/rpc.go:85-91), so the asymmetry is what a reader will trip on. StartGRPCServer takes no logger today, so this needs either a logger parameter or the check moved to the caller in sei-cosmos/server/start.go:421, which has one.

Codex flagged the same thing independently.

// StreamRateLimitInterceptor returns a server interceptor that applies per-IP
// token-bucket rate limiting when a client stream is established. registry must
// be non-nil.
func StreamRateLimitInterceptor(registry *ratelimiter.Registry) grpc.StreamServerInterceptor {

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] Worth stating the residual gap in this godoc: one token is spent at stream establishment and nothing after. The only streaming surface on :9090 is server reflection, and ServerReflectionInfo is bidirectional — a client whose single stream is admitted can then send unbounded FileContainingSymbol/ListServices messages over it, each doing a descriptor lookup, with no further accounting.

If per-stream admission is the intended scope for Phase 1, say so here (and ideally note the follow-up), so the next reader does not take "rate limiting when a client stream is established" to mean streams are throttled per message.

@seidroid
seidroid Bot dismissed stale reviews from themself August 28, 2026 14:13

Superseded: latest AI review found no blocking issues.

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