Skip to content

Commit e93ff5e

Browse files
authored
feat(rpc): add NewWithCommitment / NewWithTimeout / NewWithTimeoutAndCommitment (#436)
Adds the three rpc.Client constructor variants requested in #414, plus a DefaultCommitment() accessor so callers can read the pinned commitment back without exporting a new field. The new constructors compose New / a small newHTTPWithTimeout helper, leaving the existing 5-minute default for callers using New unchanged. - NewWithCommitment(url, commitment) pins a CommitmentType on the returned *Client. Methods that take an explicit commitment continue to honor whatever the caller passes; the stored value is exposed through Client.DefaultCommitment so consumers can use it as a fallback rather than threading it through every call site themselves. - NewWithTimeout(url, timeout) lifts the hardcoded http.Client timeout. The same value is also bound to the dialer's connect timeout and the transport's idle connection timeout so long-haul reads, connect, and pool eviction stay aligned. - NewWithTimeoutAndCommitment is the combined variant, mirroring the rust-sdk RpcClient::new_with_timeout_and_commitment ergonomics. Adds rpc/client_constructors_test.go covering the empty-default case, each constructor's stored state, and the http.Client.Timeout the custom-timeout constructors emit. Fixes #414
1 parent 950b110 commit e93ff5e

2 files changed

Lines changed: 160 additions & 4 deletions

File tree

rpc/client.go

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ var (
3535
)
3636

3737
type Client struct {
38-
rpcURL string
39-
rpcClient JSONRPCClient
38+
rpcURL string
39+
rpcClient JSONRPCClient
40+
defaultCommitment CommitmentType
4041
}
4142

4243
type JSONRPCClient interface {
@@ -67,6 +68,51 @@ func NewWithHeaders(rpcEndpoint string, headers map[string]string) *Client {
6768
return NewWithCustomRPCClient(rpcClient)
6869
}
6970

71+
// NewWithCommitment creates a new Solana JSON RPC client and pins a default
72+
// CommitmentType on the returned Client. Methods that take an explicit
73+
// CommitmentType still receive whatever the caller passes; the stored
74+
// commitment is exposed via Client.DefaultCommitment so callers can fall
75+
// back to it without threading the value through every call site
76+
// themselves. Mirrors the rust-sdk RpcClient::new_with_commitment ergonomics.
77+
func NewWithCommitment(rpcEndpoint string, commitment CommitmentType) *Client {
78+
cl := New(rpcEndpoint)
79+
cl.defaultCommitment = commitment
80+
return cl
81+
}
82+
83+
// NewWithTimeout creates a new Solana JSON RPC client with a custom HTTP
84+
// timeout. The default 5-minute timeout used by New is replaced with the
85+
// supplied value on the underlying *http.Client; the same value is also
86+
// applied to the dialer and idle connection timeout so long-haul reads,
87+
// connect, and pool eviction stay aligned.
88+
func NewWithTimeout(rpcEndpoint string, timeout time.Duration) *Client {
89+
opts := &jsonrpc.RPCClientOpts{
90+
HTTPClient: newHTTPWithTimeout(timeout),
91+
}
92+
rpcClient := jsonrpc.NewClientWithOpts(rpcEndpoint, opts)
93+
return NewWithCustomRPCClient(rpcClient)
94+
}
95+
96+
// NewWithTimeoutAndCommitment combines NewWithTimeout and NewWithCommitment.
97+
// Mirrors the rust-sdk RpcClient::new_with_timeout_and_commitment
98+
// constructor.
99+
func NewWithTimeoutAndCommitment(
100+
rpcEndpoint string,
101+
timeout time.Duration,
102+
commitment CommitmentType,
103+
) *Client {
104+
cl := NewWithTimeout(rpcEndpoint, timeout)
105+
cl.defaultCommitment = commitment
106+
return cl
107+
}
108+
109+
// DefaultCommitment returns the CommitmentType pinned on this Client at
110+
// construction time via NewWithCommitment / NewWithTimeoutAndCommitment.
111+
// Returns the empty CommitmentType when no default was configured.
112+
func (cl *Client) DefaultCommitment() CommitmentType {
113+
return cl.defaultCommitment
114+
}
115+
70116
// Close closes the client.
71117
func (cl *Client) Close() error {
72118
if cl.rpcClient == nil {
@@ -113,10 +159,29 @@ func newHTTPTransport() *http.Transport {
113159
// newHTTP returns a new Client from the provided config.
114160
// Client is safe for concurrent use by multiple goroutines.
115161
func newHTTP() *http.Client {
116-
tr := newHTTPTransport()
162+
return newHTTPWithTimeout(defaultTimeout)
163+
}
117164

165+
// newHTTPWithTimeout returns a new *http.Client whose request timeout, dial
166+
// timeout, and idle connection timeout are all bound to the supplied value.
167+
// Used by NewWithTimeout / NewWithTimeoutAndCommitment so callers can lift
168+
// the hardcoded 5-minute ceiling without dropping into newHTTPTransport.
169+
func newHTTPWithTimeout(timeout time.Duration) *http.Client {
170+
tr := &http.Transport{
171+
IdleConnTimeout: timeout,
172+
MaxConnsPerHost: defaultMaxIdleConnsPerHost,
173+
MaxIdleConnsPerHost: defaultMaxIdleConnsPerHost,
174+
Proxy: http.ProxyFromEnvironment,
175+
DialContext: (&net.Dialer{
176+
Timeout: timeout,
177+
KeepAlive: defaultKeepAlive,
178+
DualStack: true,
179+
}).DialContext,
180+
ForceAttemptHTTP2: true,
181+
TLSHandshakeTimeout: 10 * time.Second,
182+
}
118183
return &http.Client{
119-
Timeout: defaultTimeout,
184+
Timeout: timeout,
120185
Transport: gzhttp.Transport(tr),
121186
}
122187
}

rpc/client_constructors_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright 2026 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
package rpc
10+
11+
import (
12+
"net/http"
13+
"reflect"
14+
"testing"
15+
"time"
16+
)
17+
18+
// Cover the constructor variants requested in #414: a default commitment can
19+
// be pinned at construction time and read back without callers having to
20+
// thread it through every method, and the HTTP timeout is no longer
21+
// hard-wired to the 5-minute default.
22+
23+
func TestNew_DefaultCommitment_EmptyByDefault(t *testing.T) {
24+
cl := New("http://localhost:8899")
25+
if cl.DefaultCommitment() != "" {
26+
t.Fatalf("expected empty default commitment, got %q", cl.DefaultCommitment())
27+
}
28+
}
29+
30+
func TestNewWithCommitment_StoresDefault(t *testing.T) {
31+
cl := NewWithCommitment("http://localhost:8899", CommitmentFinalized)
32+
if cl.DefaultCommitment() != CommitmentFinalized {
33+
t.Fatalf("expected %q, got %q", CommitmentFinalized, cl.DefaultCommitment())
34+
}
35+
}
36+
37+
func TestNewWithTimeout_HonorsTimeoutOnHTTPClient(t *testing.T) {
38+
timeout := 17 * time.Second
39+
cl := NewWithTimeout("http://localhost:8899", timeout)
40+
httpClient := extractHTTPClient(t, cl)
41+
if httpClient.Timeout != timeout {
42+
t.Fatalf("expected http.Client.Timeout=%s, got %s", timeout, httpClient.Timeout)
43+
}
44+
}
45+
46+
func TestNewWithTimeout_DefaultCommitmentEmpty(t *testing.T) {
47+
cl := NewWithTimeout("http://localhost:8899", 30*time.Second)
48+
if cl.DefaultCommitment() != "" {
49+
t.Fatalf("expected empty default commitment, got %q", cl.DefaultCommitment())
50+
}
51+
}
52+
53+
func TestNewWithTimeoutAndCommitment_StoresBoth(t *testing.T) {
54+
timeout := 7 * time.Second
55+
cl := NewWithTimeoutAndCommitment("http://localhost:8899", timeout, CommitmentConfirmed)
56+
if cl.DefaultCommitment() != CommitmentConfirmed {
57+
t.Fatalf("expected %q, got %q", CommitmentConfirmed, cl.DefaultCommitment())
58+
}
59+
httpClient := extractHTTPClient(t, cl)
60+
if httpClient.Timeout != timeout {
61+
t.Fatalf("expected http.Client.Timeout=%s, got %s", timeout, httpClient.Timeout)
62+
}
63+
}
64+
65+
// extractHTTPClient pulls the *http.Client back out of the underlying
66+
// jsonrpc.RPCClient so the constructor's timeout choice can be asserted
67+
// without exporting new fields. The reflective access is scoped to this
68+
// test file.
69+
func extractHTTPClient(t *testing.T, cl *Client) *http.Client {
70+
t.Helper()
71+
rpcClient := cl.rpcClient
72+
if rpcClient == nil {
73+
t.Fatal("rpc client is nil")
74+
}
75+
v := reflect.ValueOf(rpcClient)
76+
if v.Kind() == reflect.Ptr {
77+
v = v.Elem()
78+
}
79+
field := v.FieldByName("httpClient")
80+
if !field.IsValid() {
81+
t.Fatalf("rpcClient %T has no httpClient field", rpcClient)
82+
}
83+
if field.IsNil() {
84+
t.Fatal("httpClient field is nil")
85+
}
86+
httpClient, ok := reflect.NewAt(field.Type(), field.Addr().UnsafePointer()).Elem().Interface().(*http.Client)
87+
if !ok {
88+
t.Fatalf("httpClient field is not *http.Client (%v)", field.Type())
89+
}
90+
return httpClient
91+
}

0 commit comments

Comments
 (0)