Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
37 changes: 34 additions & 3 deletions sdk/shreds/go/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@ package shreds
import (
"bytes"
"io"
"net"
"net/http"
"time"

"github.com/gagliardetto/solana-go/rpc"
"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.
Expand Down Expand Up @@ -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{
Expand Down
43 changes: 43 additions & 0 deletions sdk/shreds/go/rpc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading