Skip to content

PLT-1125: Bound concurrent in-flight RPCs and connections per IP on the gRPC query plane - #4078

Open
amir-deris wants to merge 3 commits into
mainfrom
amir/plt-1125-bound-concurrent-grpc-requests
Open

PLT-1125: Bound concurrent in-flight RPCs and connections per IP on the gRPC query plane#4078
amir-deris wants to merge 3 commits into
mainfrom
amir/plt-1125-bound-concurrent-grpc-requests

Conversation

@amir-deris

@amir-deris amir-deris commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes PLT-1125.

Impact

Caps how much load a single client IP can place on the gRPC query plane (:9090 and :9091):

Both planes share the same per-IP pools. Rejections are counted separately from rate-limit rejections (rpc_connection_rejected_total, rpc_inflight_rejected_total).

Operators: clients behind a shared egress or reaching the node over 127.0.0.1 share one allowance when a positive cap is set.

amir-deris and others added 2 commits September 2, 2026 14:32
Per-IP admission on the gRPC query plane charges one token at the HTTP/2
HEADERS frame and records the admitted IP on the RPC context. A client can
spend a token on headers alone, withhold the request body while the bucket
refills, repeat across streams, and release every body at once. The token
bucket smooths arrival rate; it caps no concurrency.

Two orthogonal controls close that, each useful on its own.

Per-IP connection cap. ConnLimitListener bounds simultaneously-open
connections per client address on :9090 and :9091, wrapping the raw listener
inside the global cap so a refused connection never spends a global slot.
Over-cap connections are closed rather than surfaced as an Accept error,
which would turn one client's excess into an outage for everyone else. It
applies whether or not rate limiting is enabled.

Per-IP in-flight RPC cap. RateLimitTapHandle takes a slot at the HEADERS
frame, after the token check, and InFlightStatsHandler returns it on
stats.End -- the only hook that brackets a stream the tap admitted, whatever
ends it. An interceptor cannot: grpc-go reaches one only after decoding the
request message, which is precisely the event a stockpiling client withholds.

Two failure modes shape the implementation. grpc-go answers an unknown or
malformed method name without emitting stats at all, so acquisition is gated
on the registered method set: a leaked slot fails closed and locks an IP out
permanently, which is worse than the burst. And gRPC-Web reaches the same
handleStream through ServeHTTP and emits the same stats events without ever
running the tap, so RateLimitHTTPMiddleware releases its own slot under a
defer and leaves no context marker, keeping the stats handler from returning
it twice.

New keys, all read behind presence guards: [grpc] max-connections-per-ip,
[grpc-web] max-connections-per-ip, and [grpc] max-in-flight-per-ip, each
defaulting to 100. Rejections are counted by rpc_inflight_rejected_total and
rpc_connection_rejected_total, siblings of rpc_rate_limit_rejected_total so
an operator can tell the controls apart.

collectRejectionMetrics now shares one reader across the package. It
installed a fresh meter provider per test, but the otel global takes only the
first, so a second caller left the earlier test collecting nothing.

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

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 2, 2026, 1:52 PM

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.59155% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.22%. Comparing base (b68026f) to head (e6fcbf8).

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

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4078      +/-   ##
==========================================
- Coverage   61.23%   60.22%   -1.02%     
==========================================
  Files        2177     2072     -105     
  Lines      190632   178395   -12237     
==========================================
- Hits       116729   107433    -9296     
+ Misses      62892    60994    -1898     
+ Partials    11011     9968    -1043     
Flag Coverage Δ
sei-chain-pr 93.19% <98.59%> (?)
sei-db 69.80% <ø> (ø)
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/conn_limit.go 100.00% <100.00%> (ø)
ratelimiter/inflight.go 100.00% <100.00%> (ø)
ratelimiter/metrics.go 50.00% <ø> (ø)
ratelimiter/registry.go 96.63% <100.00%> (+0.56%) ⬆️
sei-cosmos/server/config/config.go 98.08% <100.00%> (+0.26%) ⬆️
sei-cosmos/server/config/toml.go 57.14% <ø> (ø)
sei-cosmos/server/grpc/grpc_web.go 70.96% <100.00%> (+10.25%) ⬆️
sei-cosmos/server/grpc/rate_limit.go 100.00% <100.00%> (ø)
sei-cosmos/server/grpc/server.go 79.76% <71.42%> (+2.83%) ⬆️

... and 109 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.

Preserve pre-upgrade behavior unless an operator opts in to a per-IP
connection cap. Update CHANGELOG to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
@amir-deris
amir-deris marked this pull request as ready for review September 2, 2026 15:58
@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes public query admission on :9090/:9091; mis-sized caps or missing trusted-proxy CIDRs can throttle legitimate clients, though defaults keep connection caps off and in-flight limits gated behind rate-limiting-enabled.

Overview
Adds per-IP admission limits on native gRPC (:9090) and gRPC-Web (:9091) beyond the existing token-bucket rate limiter from #4021.

Connections: optional [grpc] max-connections-per-ip and [grpc-web] max-connections-per-ip (default 0 = unlimited) wrap the listener before the global max-open-connections cap. Over-limit TCP dials are closed immediately and counted in rpc_connection_rejected_total{plane}.

Concurrent RPCs: [grpc] max-in-flight-per-ip (default 100) applies when rate-limiting-enabled = true. Slots are taken at HTTP/2 HEADERS (tap) or gRPC-Web HTTP middleware and released on RPC end via a stats handler (native) or defer (Web). Rejections use ResourceExhausted / HTTP 429 with message distinct from rate limiting, metered as rpc_inflight_rejected_total{plane, method_namespace}. In-flight slots are only charged for registered gRPC methods so unknown-method probes cannot leak slots.

Both planes share the same per-IP keying as rate-limit buckets (including IPv6 /64). Config, app.toml template, cosmosbase agreement tests, and integration tests cover shared pools across planes, client cancel, and connection slot recycling.

Reviewed by Cursor Bugbot for commit e6fcbf8. Bugbot is set up for automated code reviews on this repo. 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 per-IP in-flight slot design (tap acquire + stats.End release, with the unknown-method exemption to avoid leaks) is careful and well tested, and the listener/config wiring is clean. The blocking issue is that the new connection cap keys on the raw TCP peer and therefore ignores trusted-proxy-cidrs, contradicting both its godoc and the CHANGELOG upgrade guide in a way that would lead an operator behind an ingress to cap their entire client population at one allowance.

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

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] [grpc-web] max-connections-per-ip has no test: TestStartGRPCServer_ConnectionsPerIPCapRefusesExcess only covers the :9090 listener, and it runs with rate-limiting-enabled = true, so the CHANGELOG's claim that the connection cap applies "regardless of rate-limiting-enabled" (the wiring is outside the rate-limit branch in both start functions) is not pinned by anything. A case with RateLimitingEnabled: false plus a positive MaxConnectionsPerIP, and one driving StartGRPCWeb with GRPCWeb.MaxConnectionsPerIP set, would cover both promises.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread ratelimiter/conn_limit.go
// share of the global connection budget. Wrap the raw listener with this before
// the global cap, so a connection this rejects never spends a global slot.
//
// Addresses are keyed the way rate-limit buckets are, so a client rotating

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] This claim does not hold when trusted-proxy-cidrs is set. Accept keys on conn.RemoteAddr(), which at accept time is always the proxy's address — there is no request and no X-Forwarded-For yet — so the connection cap cannot use the forwarded client IP that Registry.IPFromHTTPRequest / IPFromGRPCContext use for the rate-limit buckets. The two keyings therefore diverge exactly in the deployment trusted-proxy-cidrs exists for.

The CHANGELOG upgrade guide states the opposite and makes it operationally load-bearing: "Keying matches the rate-limit buckets: … as does any population behind a single egress address that trusted-proxy-cidrs does not cover", immediately followed by "Operators exposing public gRPC query endpoints may want to set [grpc] max-connections-per-ip (for example to 100)". An operator whose ingress CIDRs are in trusted-proxy-cidrs reads that as per-client keying, sets 100, and caps their entire ingress at 100 simultaneous connections on :9090 (and again on :9091) — a self-inflicted capacity cut, while the abusive client the cap targets is still indistinguishable from every other client behind that proxy.

Either skip the cap for peers matching trustedProxies (the cap can protect nothing behind them anyway), or state plainly here and in the CHANGELOG that this cap is keyed on the TCP peer only, is unaffected by trusted-proxy-cidrs, and must not be set to a per-client value on a node behind a proxy or load balancer.

}
// Same ordering as :9090: the per-IP cap sits below the global one, so an
// address at its limit cannot consume the shared budget to be refused.
listener = ratelimiter.ConnLimitListener(listener, ratelimiter.PlaneGRPC, clampToMaxInt(config.GRPCWeb.MaxConnectionsPerIP))

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] Both listeners pass ratelimiter.PlaneGRPC, so every rpc_connection_rejected_total sample carries plane="grpc" and an operator cannot tell a :9091 refusal from a :9090 one — even though the two caps are configured by separate keys and enforced by separate listeners with separate counters. Sharing the label made sense for rpc_rate_limit_rejected_total (both planes really do draw one bucket), but here it hides which limit fired.

A distinct constant is cheap for this metric specifically: rpc_connection_rejected_total carries no method label, so a new plane value never reaches bucketRPCMethod and cannot change method bucketing for the web plane's other counters.

grpc.InTapHandle(RateLimitTapHandle(registry)),
// The tap handler takes a per-IP concurrency slot alongside the token;
// this is what gives it back, on every path a stream can end.
grpc.StatsHandler(InFlightStatsHandler(registry)),

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 stats handler is installed whenever rate-limiting-enabled is true, including when max-in-flight-per-ip = 0. In that configuration registry.inflight is nil, AcquireInFlight always returns true and ReleaseInFlight is a no-op, so HandleRPC can never do any work — but grpc-go still allocates and dispatches Begin/InHeader/InPayload/OutPayload/OutTrailer/End for every RPC on the query plane, because it only builds those events when at least one stats handler is registered. Gating this option on cfg.MaxInFlightPerIP > 0 keeps the off switch free.

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