From 20be9f37c1b38c0ceae2756749be86e1ed3f243b Mon Sep 17 00:00:00 2001 From: Nik Weidenbacher Date: Fri, 3 Jul 2026 18:39:08 +0000 Subject: [PATCH] sdk/shreds: bound RPC client request timeout and connection pool The shreds SDK RPC client wrapped http.DefaultClient, which has no request timeout and keeps only 2 idle connections per host. Against a slow or degraded ledger RPC endpoint a request can block indefinitely, long enough for a transaction's recent blockhash to expire before it is sent, surfacing as BlockhashNotFound. Configure the underlying http.Client with a bounded 15s per-request timeout and a connection pool sized for concurrent use so requests fail fast and the caller can retry with a fresh blockhash. --- CHANGELOG.md | 2 ++ sdk/shreds/go/rpc.go | 37 ++++++++++++++++++++++++++++++--- sdk/shreds/go/rpc_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 sdk/shreds/go/rpc_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2180bee749..e18db8dc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. ### Changes +- SDK + - Give the shreds SDK RPC client a bounded per-request timeout (15s) and a sized connection pool instead of the unbounded `http.DefaultClient`, so a slow or degraded RPC endpoint fails fast rather than blocking until a transaction's blockhash expires and its send fails preflight with `BlockhashNotFound`. - E2E - Fix the multicast settlement QA test's seat-allocation ack wait. It read the reused client seat at finalized commitment and could accept the previous run's already-acked state, then withdraw while the current request was still pending. It now waits to observe the request pending before treating a cleared flag as the ack. (#3972) diff --git a/sdk/shreds/go/rpc.go b/sdk/shreds/go/rpc.go index 7212c5dc13..0fe5da5b58 100644 --- a/sdk/shreds/go/rpc.go +++ b/sdk/shreds/go/rpc.go @@ -3,6 +3,7 @@ package shreds import ( "bytes" "io" + "net" "net/http" "time" @@ -10,7 +11,19 @@ import ( "github.com/gagliardetto/solana-go/rpc/jsonrpc" ) -const defaultMaxRetries = 5 +const ( + defaultMaxRetries = 5 + + // defaultRequestTimeout bounds each individual RPC request. http.DefaultClient has no timeout, + // so against a slow or degraded RPC endpoint a request can block indefinitely — long enough + // for a transaction's recent blockhash to expire before it is sent, surfacing as + // BlockhashNotFound. A short timeout fails fast so the caller can retry with a fresh blockhash. + defaultRequestTimeout = 15 * time.Second + + // defaultMaxConns caps concurrent connections to the RPC host. http.DefaultClient's transport + // keeps only 2 idle connections per host, which throttles concurrent callers. + defaultMaxConns = 128 +) // retryHTTPClient wraps an http.Client and retries on transient errors: // network failures (EOF, connection reset), HTTP 429, and HTTP 5xx. @@ -62,10 +75,28 @@ func (c *retryHTTPClient) CloseIdleConnections() { c.inner.CloseIdleConnections() } -// NewRPCClient creates a Solana RPC client with automatic retry on transient errors. +// newHTTPClient returns an http.Client with a bounded per-request timeout and a connection pool +// sized for concurrent use, instead of the unbounded, lightly-pooled http.DefaultClient. +func newHTTPClient(timeout time.Duration, maxConns int) *http.Client { + transport := &http.Transport{ + MaxConnsPerHost: maxConns, + MaxIdleConns: maxConns, + MaxIdleConnsPerHost: maxConns, + IdleConnTimeout: 90 * time.Second, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + } + return &http.Client{Timeout: timeout, Transport: transport} +} + +// NewRPCClient creates a Solana RPC client with a bounded request timeout and automatic retry on +// transient errors. func NewRPCClient(url string) *rpc.Client { httpClient := &retryHTTPClient{ - inner: http.DefaultClient, + inner: newHTTPClient(defaultRequestTimeout, defaultMaxConns), maxRetries: defaultMaxRetries, } rpcClient := jsonrpc.NewClientWithOpts(url, &jsonrpc.RPCClientOpts{ diff --git a/sdk/shreds/go/rpc_test.go b/sdk/shreds/go/rpc_test.go new file mode 100644 index 0000000000..f3f7501ea7 --- /dev/null +++ b/sdk/shreds/go/rpc_test.go @@ -0,0 +1,43 @@ +package shreds + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewHTTPClient_HasBoundedTimeout(t *testing.T) { + c := newHTTPClient(defaultRequestTimeout, defaultMaxConns) + if c.Timeout == 0 { + t.Fatal("http client must have a bounded timeout, not http.DefaultClient's infinite one") + } + if c.Timeout != defaultRequestTimeout { + t.Fatalf("expected timeout %s, got %s", defaultRequestTimeout, c.Timeout) + } + tr, ok := c.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", c.Transport) + } + if tr.MaxConnsPerHost != defaultMaxConns { + t.Fatalf("expected MaxConnsPerHost %d, got %d", defaultMaxConns, tr.MaxConnsPerHost) + } +} + +func TestNewHTTPClient_TimesOutSlowRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + })) + defer srv.Close() + + c := newHTTPClient(200*time.Millisecond, defaultMaxConns) + start := time.Now() + resp, err := c.Get(srv.URL) + if err == nil { + resp.Body.Close() + t.Fatal("expected request to time out, but it succeeded") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("request should have timed out near 200ms, took %s (client not bounding requests)", elapsed) + } +}