Skip to content

solana/rpc: record endpoint rate-limit headroom per method - #4161

Open
bgm-malbeclabs wants to merge 2 commits into
mainfrom
solana/observe-ratelimit-headers
Open

solana/rpc: record endpoint rate-limit headroom per method#4161
bgm-malbeclabs wants to merge 2 commits into
mainfrom
solana/observe-ratelimit-headers

Conversation

@bgm-malbeclabs

Copy link
Copy Markdown
Contributor

Summary

A 429 from a ledger RPC endpoint is currently opaque. solana-go keeps only a code on its error types:

type HTTPError struct { Code int; err error }
type RPCError  struct { Code int; Message string; Data any }

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

This adds a passive RoundTripper on 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 getTransaction for 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-* and dz-* namespace: zero lines contain X-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 getTransaction against the mainnet ledger endpoint returns no X-Ratelimit-* headers at all on a 200:

    HTTP/2 200
    content-type: application/json; charset=utf-8
    x-rpc-node: lb-pit5
    access-control-allow-origin: *
    alt-svc: h3=":443"; ma=86400
    

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

  • Header check first. resp.Header.Get on two keys; if both are absent it returns immediately. No body inspection, no allocation, no series.
  • Body read only when there is something to record, and through req.GetBody() so the body the transport actually sends is untouched. Bounded to 512 bytes — JSON-RPC puts method near the front.
  • Never alters a request or response, and never fails a call. A body it cannot parse, or a request without GetBody, is silently skipped.
  • Batch requests are labelled batch, mirroring the batchLabel the 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 via GetBody correct).
  • 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; gofmt and go vet clean.

Note on scope

This deliberately does not add an Options.Transport hook. 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 — isRetryableHTTPStatus handles 429 — so surfacing why belongs here too, once, for everyone.

@bgm-malbeclabs
bgm-malbeclabs force-pushed the solana/observe-ratelimit-headers branch from 499afa7 to f67b849 Compare August 7, 2026 00:42
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 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.)
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