Skip to content

feat(rate-limit): meter cheap reads separately and expose usage headers - #1741

Merged
MuncleUscles merged 1 commit into
v0.121from
feat/v0.121-rate-limit-read-bucket
Aug 17, 2026
Merged

feat(rate-limit): meter cheap reads separately and expose usage headers#1741
MuncleUscles merged 1 commit into
v0.121from
feat/v0.121-rate-limit-read-bucket

Conversation

@MuncleUscles

Copy link
Copy Markdown
Member

What

Three related changes to how /api traffic is rate limited.

1. Cheap reads meter into their own bucket. RateLimitMiddleware checked the quota before the JSON-RPC body was ever parsed, so every method cost the same. Reads that never enter the GenVM now meter into ratelimit:{identity}:read:{window} at RATE_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 /api response, plus on the 429 — Limit, Remaining, Reset, Window, Bucket — reported for whichever window is closest to exhaustion. Added to CORS expose_headers.

3. gen_getContractCode no longer loads the whole contract state. The code slot is extracted in SQL via fetch_deployed_code_b64 instead of constructing a full ContractSnapshot.

New file backend/protocol_rpc/rate_limit_methods.py holds 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-After on 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 ContractSnapshot pulls the entire data JSONB — every storage slot the contract owns — to read one deterministic slot out of it.

Testing done

  • 779 unit tests pass (24 new)
  • tests/unit/test_rate_limiter_lua.py executes the Lua script against a stubbed redis.call implementing sorted-set semantics. The script previously had no real coverage — every test mocks evalsha — so a syntax error in it would have surfaced first in production on every /api request. Skips when no lua interpreter is present.
  • tests/unit/test_contract_code_fetch.py covers nested/flat/legacy state shapes and compiles the generated SQL against the Postgres dialect, which mocks cannot validate.
  • Classification is tested for GenVM-bound methods, mixed batches, and malformed/oversized bodies.

Decisions made

  • The read allowlist is hand-maintained, not derived. DISABLE_INFO_LOGS_ENDPOINTS looks like the natural source but contains eth_call, which routes through _admit_genvm_call and can fan out to LLM validators — reusing that list would have made the most expensive call in the system free. gen_getContractSchema and gen_getContractSchemaForCode are excluded for the same reason: both build a Node backed by a GenVMManager.
  • Ambiguity charges the stricter bucket. Unparseable, oversized (>64KB), or a batch containing one expensive member all fall back to standard.
  • The standard bucket keeps its Redis key shape so limits in flight at deploy time carry over rather than silently resetting.
  • Reset is time-until-oldest-entry-ages-out, not the window length, which is the honest value for a sliding window.
  • Legacy and undeployed row shapes defer to ContractSnapshot rather than reimplementing its error semantics in SQL.
  • Reading the body in BaseHTTPMiddleware is safe here because Starlette 0.52.1 wraps the request in _CachedRequest, which replays the buffered body downstream. Verified in the installed source.

Checks

  • I have tested this code
  • I have reviewed my own PR
  • I have created an issue for this PR
  • I have set a descriptive PR title compliant with conventional commits

Reviewing tips

  • The allowlist in rate_limit_methods.py is 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.
  • This middleware fronts 100% of /api POST traffic, so the fail-open envelope in dispatch matters — body-parse failures must not reject requests.
  • The DB win is partial and unmeasured: Postgres still detoasts the JSONB server-side, so what is saved is wire transfer and Python deserialization, not the read itself. If it benchmarks marginal, the 10x multiplier deserves a second look.
  • Pre-existing and not addressed here: a batch array of N writes still counts as 1 request.

User facing release notes

  • API responses now include X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Window and X-RateLimit-Bucket, so clients can pace themselves instead of discovering the limit by hitting it.
  • Read-only RPC calls that do not execute contract code now have a substantially higher rate limit than contract execution calls.

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

Copy link
Copy Markdown
Member Author

/run-e2e studio

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 295d3a3c-3d7d-4d9e-b079-87e3bd637154

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@MuncleUscles

Copy link
Copy Markdown
Member Author

CodeQL py/weak-sensitive-data-hashing — dismissed as false positive

Alert #187 fired on hashlib.sha256(raw_key.encode()) in backend/protocol_rpc/rate_limiter.py, claiming SHA-256 is unsuitable because it is not computationally expensive.

This PR does not touch that line. git blame puts it at 93335ff (Feb 2026, #1445); it surfaced here only because changes to check_rate_limit altered the call path reaching it.

The finding is also wrong on the merits. The hashed value is not a password:

  1. It is a 256-bit CSPRNG token. API keys are minted as "glk_" + secrets.token_hex(32) in admin_create_api_key. Slow KDFs (bcrypt, argon2, PBKDF2) exist to make brute-force and dictionary attacks expensive against low-entropy, human-chosen secrets. Against 2^256 of uniform randomness they buy nothing — the search is infeasible regardless of how fast the hash is.
  2. Fast hashing is a functional requirement. This runs on the rate limiter's hot path, once per /api request. A deliberately expensive KDF would add roughly 100ms of latency to every call — degrading the exact thing this PR is trying to improve.
  3. Changing it is a breaking change. Only the hash is persisted in api_keys; the raw key is unrecoverable by design. A different algorithm invalidates every key already issued.

SHA-256 over a high-entropy token is the standard construction for API key storage, and is what GitHub and Stripe do for the same reason.

Left open deliberately, as out of scope here: 8 × py/stack-trace-exposure (medium) look like they may be legitimate and are worth triaging separately.

@MuncleUscles

Copy link
Copy Markdown
Member Author

/run-e2e all

@MuncleUscles
MuncleUscles merged commit f3a17a5 into v0.121 Aug 17, 2026
16 checks passed
MuncleUscles added a commit that referenced this pull request Aug 18, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant