Architecture¶
RPC Plane sits between your application and your Solana RPC providers. It makes sub-millisecond routing decisions using pre-computed health scores and returns the response from the selected provider.
┌──────────────────────────────────────────┐
│ RPC Plane │
│ │
App request ─────▶│ Ingress (HTTP) │
│ │ │
│ ▼ │
│ Request Classifier │
│ (read vs write, method) │
│ │ │
│ ▼ │
│ Router ◄──── Health Scorer │
│ (select provider) ▲ │
│ │ │ │
│ │ Health Probe │
│ │ (1s getSlot loop) │
│ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Provider │ │Provider │ │Provider │ │
│ │ A │ │ B │ │ C │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Response / Retry logic │
│ │ │
App response ◄────│ Egress │
│ │
│ Prometheus metrics ──▶ :9401/metrics │
└──────────────────────────────────────────┘
Request flow¶
- Ingress — the proxy accepts the JSON-RPC request (HTTP POST).
- Request classifier — parses the
methodfield and classifies it as a read or a write. - Router — selects a provider (or set of providers) based on current health scores and the configured strategy.
- Provider call — the proxy forwards the request. For broadcasts it fans out concurrently.
- Retry / failover — on retryable errors (429, 5xx, timeout) the proxy tries the next provider in the ordered list.
- Egress — the first successful response is returned to the caller.
All steps happen in the hot path. Health scores are pre-computed by background tasks; the router reads them from a shared atomic snapshot without any blocking I/O.
Background tasks¶
One health probe loop runs per provider (default: every 1s, health.interval_ms).
Each cycle issues three concurrent getSlot requests — one per commitment (processed, confirmed, finalized) — over the warm connection pool. Separate requests (not a JSON-RPC batch) keep the probe on the same path as real traffic and avoid provider-side batch handling. From the responses the proxy:
- measures round-trip latency (from the
processedcall, the liveness signal) and feeds it to the health score; - tracks probe success/failure, updating the rolling error rate, consecutive-failure counter, and circuit state;
- records the per-commitment slot heights and computes a per-commitment network tip (max slot across all providers at that commitment) and the per-provider drift against it.
Per-commitment tips matter: finalized trails processed by ~32 slots by design, so comparing it to the processed tip would flag every provider as drifting. The slot component of the score uses the worse of processed/confirmed drift, so a provider whose confirmed pipeline stalls while processed stays fresh is still demoted. finalized drift is exported for visibility but excluded from scoring.
Probe cost on metered providers
Three getSlot calls per provider per second ≈ 260k calls/provider/day. getSlot is among the cheapest RPC methods, but on strictly metered plans this counts against quota. Raise health.interval_ms to probe less often.
Submission-only providers (scoped to methods without getSlot) skip the probe entirely; their health is driven by live request outcomes.
Health score¶
See Health scoring for details.
Routing strategies¶
See Routing for details on each strategy.
Write-path broadcast¶
Optional. When routing.broadcast_writes = true, every method in routing.write_methods (default: sendTransaction) is sent to all healthy providers simultaneously, returning the fastest success. Off by default — writes use the same strategy as reads. simulateTransaction is read-only and routes like a read unless added to write_methods. Providers can be scoped to specific methods (e.g. a submission-only landing service) via a per-provider methods allowlist. See Routing for details.
Retry logic¶
On a retryable error the proxy tries the next provider in the ordered list (up to max_retries, default 2). See Routing — Retries for the full error-code table.
Hot reload¶
The proxy watches the config file for changes (checked every 2 seconds). When the file is modified:
- Added providers — new health monitor and slot tracker tasks start immediately.
- Removed providers — their background tasks are stopped.
- Client-input changes — a provider whose
urlorhttp3flag changed, or any provider whenserver.pool_max_idle_per_hostchanged, has its outbound client rebuilt (treated as remove + add, which resets health state). - Unchanged providers — accumulated health history is preserved. A
weight-only change applies live without a rebuild.
Resetting health state on a client rebuild is deliberate: the accumulated score, latency EMA, error window, and circuit state were all measured against the old endpoint and connection pool. Carrying them onto a different upstream would misreport the new target's health until every sample aged out — a clean slate lets the next probe cycle describe the endpoint you actually pointed it at.
server.listen, server.metrics_listen, server.listen_backlog, and server.worker_threads require a restart; a warning is logged if one of them changes.
Key design constraints¶
Sub-millisecond routing. Health scores are pre-computed in background tasks and stored in a RwLock<Arc<...>> snapshot. The router reads the snapshot and makes an O(N) sort — no I/O in the hot path.
Bounded control-plane state. Health and routing state does not grow with uptime or traffic: the error rate uses a fixed sliding window and slot state is a fixed set of per-commitment slot heights per provider. The data plane is separate — it buffers each in-flight response body in full before returning it, so peak memory scales with concurrency × response size, not with how long the process has run. That is working set, not a leak; size container limits for your busiest response mix (large getBlock / getProgramAccounts payloads under high concurrency are the ones to watch).
No single point of failure. All panic paths are caught at the request handler level. One bad request or provider response cannot crash the proxy.
Zero mandatory infrastructure. Everything runs in-process. No external databases, caches, or service dependencies.