feat(rate-limit): meter cheap reads separately and expose usage headers - #1741
Conversation
Rate limiting was method-agnostic: the middleware checked the quota before the JSON-RPC body was parsed, so `gen_getContractCode` — a single indexed read — cost exactly what a write fanning out to LLM validators cost. That forces the limits to be sized for the expensive case, which starves the cheap read traffic that batch tooling generates. Reads that never enter the GenVM now meter into their own bucket at RATE_LIMIT_READ_MULTIPLIER (default 10x) of the tier limits. The standard bucket keeps its key shape and numbers, so limits in flight at deploy time carry over rather than resetting. The allowlist is maintained by hand and deliberately conservative. Two methods that read like lookups are excluded because they build a Node backed by a GenVMManager (gen_getContractSchema, gen_getContractSchemaForCode), and eth_call is excluded for the same reason despite appearing in the DISABLE_INFO_LOGS_ENDPOINTS env list that otherwise looks like the natural source for this. Anything ambiguous — unparseable, oversized, or a batch with one expensive member — is charged to the stricter bucket. Responses now carry X-RateLimit-Limit/Remaining/Reset/Window/Bucket for the window closest to exhaustion, so clients can pace themselves instead of discovering the ceiling by hitting it. Reset is the time until the oldest entry ages out, which is the honest answer for a sliding window. These are listed in the CORS expose_headers, without which browsers hide them from JS. Finally, gen_getContractCode no longer loads the whole contract state to read one slot out of it. ContractSnapshot pulls the entire `data` JSONB — every storage slot the contract owns — which for a contract holding a large vector store is a substantial fetch and deserialize on a call that batch tooling polls hard. The slot is now extracted in SQL. Postgres still detoasts the JSONB server-side, so this narrows transfer and parse cost rather than eliminating the read; legacy and undeployed row shapes defer to the original path to keep their error semantics intact. The Lua script gains a test that actually executes it under a stubbed redis.call. It was previously covered only through mocked evalsha, so an error in the script body would have surfaced first in production, on every /api request.
|
/run-e2e studio |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
CodeQL
|
|
/run-e2e all |
…keys to v0.123 (#1743) * feat(rate-limit): meter cheap reads separately and expose usage headers (#1741) Rate limiting was method-agnostic: the middleware checked the quota before the JSON-RPC body was parsed, so `gen_getContractCode` — a single indexed read — cost exactly what a write fanning out to LLM validators cost. That forces the limits to be sized for the expensive case, which starves the cheap read traffic that batch tooling generates. Reads that never enter the GenVM now meter into their own bucket at RATE_LIMIT_READ_MULTIPLIER (default 10x) of the tier limits. The standard bucket keeps its key shape and numbers, so limits in flight at deploy time carry over rather than resetting. The allowlist is maintained by hand and deliberately conservative. Two methods that read like lookups are excluded because they build a Node backed by a GenVMManager (gen_getContractSchema, gen_getContractSchemaForCode), and eth_call is excluded for the same reason despite appearing in the DISABLE_INFO_LOGS_ENDPOINTS env list that otherwise looks like the natural source for this. Anything ambiguous — unparseable, oversized, or a batch with one expensive member — is charged to the stricter bucket. Responses now carry X-RateLimit-Limit/Remaining/Reset/Window/Bucket for the window closest to exhaustion, so clients can pace themselves instead of discovering the ceiling by hitting it. Reset is the time until the oldest entry ages out, which is the honest answer for a sliding window. These are listed in the CORS expose_headers, without which browsers hide them from JS. Finally, gen_getContractCode no longer loads the whole contract state to read one slot out of it. ContractSnapshot pulls the entire `data` JSONB — every storage slot the contract owns — which for a contract holding a large vector store is a substantial fetch and deserialize on a call that batch tooling polls hard. The slot is now extracted in SQL. Postgres still detoasts the JSONB server-side, so this narrows transfer and parse cost rather than eliminating the read; legacy and undeployed row shapes defer to the original path to keep their error semantics intact. The Lua script gains a test that actually executes it under a stubbed redis.call. It was previously covered only through mocked evalsha, so an error in the script body would have surfaced first in production, on every /api request. * feat(rate-limit): accept API keys in the URL path (#1742) Keys could only be supplied as an X-API-Key header, which makes Studio unusable from most of the EVM toolchain. MetaMask's "Add network" takes a URL and has no header field at all; the same is true of foundry.toml, --rpc-url, and most viem/ethers setups. Every major provider puts the key in the path for this reason (Alchemy /v2/<key>, Infura /v3/<key>). This is not hypothetical: an integrator on a shared Studio key told us they would have to stand up a backend proxy "for them to be able to use it properly as most of the use comes from testing deployments through metamask". That proxy's whole job would be turning a URL into a header, and a shared proxy is also what pushes teams onto one shared key instead of one key per person. POST /api/{api_key} now routes to the same JSON-RPC handler, with the header still accepted for server-side callers. The path wins when both are present, since it is what the caller typed rather than something an intermediary may have injected. Only `glk_`-prefixed segments count as keys: any other segment falls through to anonymous rather than erroring, while a mistyped real key is still treated as a key so it fails loudly as invalid instead of silently dropping to anonymous limits. The middleware's path gate now covers every path the route can match, not just key-shaped ones. A path served by the route but missed by the gate would be an unlimited, unauthenticated RPC endpoint, so a test pins that correspondence. Keys in URLs get written down in places headers are not, and two of those were ours: uvicorn access logging is on, and Sentry runs with send_default_pii and full trace sampling, so request URLs leave the process on every transaction, not only on errors. api_key_redaction scrubs keys from both — a logging filter covering uvicorn's args-based access line, and before_send/before_send_transaction hooks that walk the whole event rather than known fields. Without this, path keys would be a downgrade on header-only rather than an improvement. * feat(rate-limit): classify the v0.123-only RPC methods The ported allowlist predates three methods that exist only on this branch. sim_getFeeConfig and gen_getTransactionStatusDetails are cheap reads — a config lookup and a transactions_processor read respectively, neither touching the GenVM. sim_estimateTransactionFees is not. It takes validators_manager and genvm_manager and runs the contract through sim_call to measure fees, so it costs a full execution. Worth flagging because eth_estimateGas *is* allowlisted — that one returns a hardcoded constant. The names are nearly identical and the costs are not, which is the same shape of trap as eth_call and the two schema methods already documented here.



What
Three related changes to how
/apitraffic is rate limited.1. Cheap reads meter into their own bucket.
RateLimitMiddlewarechecked the quota before the JSON-RPC body was ever parsed, so every method cost the same. Reads that never enter the GenVM now meter intoratelimit:{identity}:read:{window}atRATE_LIMIT_READ_MULTIPLIER(default 10) times the tier limits. The standard bucket keeps its original key shape and numbers.2.
X-RateLimit-*headers on every/apiresponse, plus on the 429 —Limit,Remaining,Reset,Window,Bucket— reported for whichever window is closest to exhaustion. Added to CORSexpose_headers.3.
gen_getContractCodeno longer loads the whole contract state. The code slot is extracted in SQL viafetch_deployed_code_b64instead of constructing a fullContractSnapshot.New file
backend/protocol_rpc/rate_limit_methods.pyholds the read allowlist and body classification.Why
The limiter was method-agnostic, so
gen_getContractCode— one indexed read — cost exactly what a write fanning out to LLM validators cost. That forces the limits to be sized for the expensive case, which starves the cheap read traffic that batch tooling generates. This surfaced as a user hitting 429s while fetching contract sources for automated reviews.The headers exist because clients currently have no way to see how close they are to the ceiling — only
Retry-Afteron rejection. The reporting user could not determine which window they had blown.The query narrowing is coupled to change 1 on purpose: raising the read limit 10x without it would have traded an LLM-cost problem for a Postgres one, since
ContractSnapshotpulls the entiredataJSONB — every storage slot the contract owns — to read one deterministic slot out of it.Testing done
tests/unit/test_rate_limiter_lua.pyexecutes the Lua script against a stubbedredis.callimplementing sorted-set semantics. The script previously had no real coverage — every test mocksevalsha— so a syntax error in it would have surfaced first in production on every/apirequest. Skips when noluainterpreter is present.tests/unit/test_contract_code_fetch.pycovers nested/flat/legacy state shapes and compiles the generated SQL against the Postgres dialect, which mocks cannot validate.Decisions made
DISABLE_INFO_LOGS_ENDPOINTSlooks like the natural source but containseth_call, which routes through_admit_genvm_calland can fan out to LLM validators — reusing that list would have made the most expensive call in the system free.gen_getContractSchemaandgen_getContractSchemaForCodeare excluded for the same reason: both build aNodebacked by aGenVMManager.Resetis time-until-oldest-entry-ages-out, not the window length, which is the honest value for a sliding window.ContractSnapshotrather than reimplementing its error semantics in SQL.BaseHTTPMiddlewareis safe here because Starlette 0.52.1 wraps the request in_CachedRequest, which replays the buffered body downstream. Verified in the installed source.Checks
Reviewing tips
rate_limit_methods.pyis the highest-risk surface. A method wrongly classified as cheap gets 10x capacity on a path that may cost real money. Worth checking each entry independently rather than trusting the grouping./apiPOST traffic, so the fail-open envelope indispatchmatters — body-parse failures must not reject requests.User facing release notes
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-RateLimit-WindowandX-RateLimit-Bucket, so clients can pace themselves instead of discovering the limit by hitting it.