solana/rpc: record endpoint rate-limit headroom per method - #4161
Open
bgm-malbeclabs wants to merge 2 commits into
Open
solana/rpc: record endpoint rate-limit headroom per method#4161bgm-malbeclabs wants to merge 2 commits into
bgm-malbeclabs wants to merge 2 commits into
Conversation
bgm-malbeclabs
force-pushed
the
solana/observe-ratelimit-headers
branch
from
August 7, 2026 00:42
499afa7 to
f67b849
Compare
A 429 from the ledger endpoint is currently opaque. solana-go keeps only a
code on its error types — *HTTPError is {Code int, err error} and *RPCError
is {Code, Message, Data} — and discards the http.Response inside
CallForInto, so the X-Ratelimit-* headers that say what the cap actually was
are gone before any caller sees the error. A service can log "Too many
requests for a specific RPC call" for hours without ever learning the number
it is exceeding.
Adds a passive RoundTripper on the outermost transport that records
X-Ratelimit-Method-Limit and X-Ratelimit-Method-Remaining as gauges labelled
by JSON-RPC method. Limits are enforced per method, per source IP, over a
rolling window, so an unattributed number would not be actionable and a
global request-rate graph does not show it.
Cheap when unused: the request body is only inspected once a response header
proves there is something to record, and it is read through GetBody so the
body the transport sends is untouched. Observation never alters a
request/response and never fails a call.
Motivating incident: a lake indexer activity was rate-limited on
getTransaction for 4.5 hours. The cap was unknowable from our side — the
headers were never captured, and the endpoint returns no X-Ratelimit-*
headers on a 200, so the vendor's suggested "read them off a response" was
not available either.
bgm-malbeclabs
force-pushed
the
solana/observe-ratelimit-headers
branch
from
August 7, 2026 00:54
f67b849 to
f79a661
Compare
This was referenced Aug 7, 2026
bgm-malbeclabs
added a commit
to malbeclabs/lake
that referenced
this pull request
Aug 12, 2026
## Summary
- A rate-limited `getTransaction` aborted the entire permission-events
refresh cycle. Committed chunks stayed durable, but the run was recorded
as an error and the drain advanced only ~one chunk per ~27min cycle —
once a backlog appeared it could never catch up. Prod sat **3h26m behind
and widening** for 4.5 hours.
- A transient decode failure *with committed progress* is now treated as
a budget stop rather than a cycle failure — the same semantic
`drainAccount` already used for its deadline check. Zero progress still
returns an error, so a persistently throttled account escalates instead
of no-op succeeding.
- `getTransaction` is now paced view-wide. The existing semaphore capped
in-flight calls but not request *rate*, which is what a per-method
provider limit actually measures — 10 concurrent calls against a fast
endpoint is an unbounded rate, so the drain kept earning `Too many
requests for a specific RPC call` on every cycle.
## Why the cycle could never catch up
```
cycle 16:09 → frontier 16:06 (3m behind)
cycle 20:39 → frontier 17:13 (3h26m behind)
```
Each cycle committed its first chunk, hit a 429, and discarded the rest
— advancing the frontier ~5 minutes per 27-minute cycle.
The graceful stop alone would have converted a loud livelock into a
silent one (success-with-pending, frontier still falling behind), which
is why the pacing change is in the same PR — they fix the same failure
together.
## Note for review
`defaultFetchesPerSecond = 25` is a calibration knob, not a derived
constant — no provider publishes a per-method number and it differs per
endpoint and plan. It is sized to drain a 200-signature chunk in ~8s so
the ~4.5min usable window fits ~30 chunks. It is behind
`--permission-events-fetches-per-second` so it can be retuned without a
rebuild. This is the value most worth a second opinion.
## Testing Verification
- New test reproduces the production failure exactly (`RPCError{Code:
429}`, the same shape RPCPool returns) and fails on the pre-change code
with the identical production error message; passes after.
- `ThrottledChunkStopsWithProgressAndResumes` — asserts the first chunk
stays committed, the cursor sits at the chunk boundary,
`SourceMaxEventTS` reports the honest committed frontier rather than
`now` (this is what feeds the ingest-staleness alert), and the next
cycle drains the remainder with no signature skipped or double-counted.
- `ThrottledFirstChunkStillErrors` — pins the guard: a throttle before
any commit must still surface an error, so suppression can't hide a
persistently throttled account.
- `PacesTransactionFetches` — asserts the drain cannot finish faster
than the configured rate allows.
- `go test -race` green across `dz/serviceability/...`, `dzingest`,
`indexer`.
## Relationship to #747
This is the permission-events instance of the follow-up #747
deliberately deferred:
> **Durable partial progress** — sub-batch inserts that advance the
watermark for what was actually written, so a failed cycle stops
discarding 5+ minutes of completed Flux work.
Same shape, different view. The part worth reading before the
telemetry-usage version gets written is the guard, not the happy path:
`start > 0 && dberror.IsTransient(decodeErr)` stops a cycle discarding
committed chunks, but **zero progress still returns an error**, so a
genuinely stuck account escalates instead of quietly succeeding forever.
Getting only the first half would convert a loud livelock into a silent
one.
Note that telemetry-usage is harder for the reason #747 gives: its
baseline cache must only advance once rows are durably written, and its
deltas depend on ordered whole-window processing. Permission events has
no equivalent coupling — chunks are independent and the cursor is
explicit — which is why it was tractable here first.
## Deploy note
**No outstanding gap.** Permission events caught up on its own once the
upstream RPC pressure eased, before this shipped:
```
20:12Z ERR (10th consecutive failure — the one that paged)
21:06Z ERR
21:34Z refresh completed in 7.9s
22:32Z refresh completed in 0.3s <- nothing left to drain
```
So this is not a recovery deploy. It ships so the *next* throttle costs
minutes of freshness instead of hours, and so a failed cycle stops
discarding work it already committed.
After deploy, the thing to verify is whether the 429s stop. If they
continue, `--permission-events-fetches-per-second` is the dial — the
approach doesn't change. (Since this PR was opened, the provider
confirmed these are per-method **and** per-IP caps over a 10s rolling
window on their edge, and every 429 carries a `Retry-After`; honoring
that header is tracked separately in malbeclabs/doublezero#4161 and is
the more durable fix than any pacing value.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A 429 from a ledger RPC endpoint is currently opaque. solana-go keeps only a code on its error types:
…and discards the
http.ResponseinsideCallForInto. So theX-Ratelimit-*headers that say what the cap actually was are gone before any caller sees the error. A service can log"Too many requests for a specific RPC call"for hours without ever learning the number it is exceeding.This adds a passive
RoundTripperon the outermost transport of every retrying client this package builds, recording:doublezero_solana_rpc_ratelimit_method_limit{method}doublezero_solana_rpc_ratelimit_method_remaining{method}Labelled by JSON-RPC method because that is how the limits are enforced — per method, per source IP, over a rolling window (confirmed with Triton). An unattributed number would not be actionable, and a global request-rate graph does not show this at all: one method can be shedding while total throughput looks healthy.
Motivating incident
A lake indexer activity was rate-limited on
getTransactionfor 4.5 hours (malbeclabs/lake#753, #757). Throughout, the cap was unknowable from our side:The headers were never captured — discarded at the solana-go layer, as above. Confirmed by searching 7 days of logs across every
lake-*anddz-*namespace: zero lines containX-Ratelimit-Method-Limit,-Remaining, or-Tier.The vendor's suggested workaround ("grab them off one of the 429 responses") was not available either. A live
getTransactionagainst the mainnet ledger endpoint returns noX-Ratelimit-*headers at all on a 200:So these gauges may only populate on the 429 path with the current endpoint configuration. That is still exactly when the number matters, and it costs nothing in the meantime — see below. If the headers later appear on successful responses, this starts showing continuous headroom with no further change.
Cost when unused
resp.Header.Geton two keys; if both are absent it returns immediately. No body inspection, no allocation, no series.req.GetBody()so the body the transport actually sends is untouched. Bounded to 512 bytes — JSON-RPC putsmethodnear the front.GetBody, is silently skipped.batch, mirroring thebatchLabelthe retry layer already uses, since a batch carries a mix of methods.Testing Verification
tools/solana/pkg/rpc/ratelimit_test.go:RecordsHeadersByMethod— headers land in the right gauges under the right method label, the response is unaltered, and the inner transport still receives the complete, unmodified body (the property that makes reading viaGetBodycorrect).NoHeadersNoSeries— the cheap-exit path: an endpoint reporting nothing produces no series and no body inspection. This is the path the DZ mainnet ledger takes today.BatchIsLabelledBatch— a batch is not misattributed to whichever method happens to be first.MalformedBodyIsSkipped— observation is best-effort; an unparseable body does not fail the request.Full
go test ./tools/solana/...passes with no regressions;gofmtandgo vetclean.Note on scope
This deliberately does not add an
Options.Transporthook. That was the other way to solve it, but it would push the work onto every caller and let them replace the tuned transport (gzip, connection pools, dial and request timeouts) by accident. Rate-limit classification already lives in this package —isRetryableHTTPStatushandles 429 — so surfacing why belongs here too, once, for everyone.