Skip to content

Commit b2b6312

Browse files
authored
feat(jsonrpc2): support http.Client injection and context-aware Send (#19)
* feat(jsonrpc2): support http.Client injection and context-aware Send - NewClientWithOptions + WithHTTPClient let callers control timeout, transport, and connection pooling - SendWithContext carries ctx so cancellation aborts the in-flight HTTP request; Send() delegates to it with context.Background() - legacy 30s per-call client kept as fallback when no client injected * docs+test: address PR review — nil-client semantics, fallback notes, timing tolerance - WithHTTPClient(nil) is now an explicit no-op (legacy 30s per-call fallback applies), documented with a transport/pooling usage snippet - SendWithContext fallback branch documents the per-call-client caveat (no connection-reuse guarantees; inject a client for pooling) - new TestWithHTTPClient_NilIsNoOp pins the nil semantics - cancel/timeout tests: elapsed bound 1s -> 2s (still far below the 5s failure mode) to tolerate loaded CI runners - README: 'JSON-RPC Client Options' section
1 parent 2d83e30 commit b2b6312

4 files changed

Lines changed: 277 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,44 @@ func main() {
8181
}
8282
```
8383

84+
### JSON-RPC Client Options (jsonrpc2)
85+
86+
By default `jsonrpc2.NewClient` sends each request through a fresh
87+
`http.Client` with a 30s timeout, and `Send()` cannot be canceled. Two
88+
capabilities let callers take control:
89+
90+
```go
91+
import (
92+
"net/http"
93+
"time"
94+
95+
"github.com/steemit/steemutil/jsonrpc2"
96+
)
97+
98+
// Custom transport + timeout, reused across requests (connection pooling).
99+
tr := http.DefaultTransport.(*http.Transport).Clone()
100+
tr.MaxIdleConnsPerHost = 16
101+
102+
client := jsonrpc2.NewClientWithOptions("https://api.steemit.com",
103+
jsonrpc2.WithHTTPClient(&http.Client{
104+
Timeout: 15 * time.Second,
105+
Transport: tr,
106+
}),
107+
)
108+
_ = client.BuildSendData("condenser_api.get_block", []any{20000000})
109+
110+
// Context-aware send: cancellation aborts the in-flight HTTP request.
111+
res, err := client.SendWithContext(ctx)
112+
```
113+
114+
Notes:
115+
116+
- `WithHTTPClient(nil)` is a no-op — the legacy per-call 30s-timeout client
117+
fallback applies (no connection-reuse guarantees; inject a client when you
118+
need pooling or a custom transport).
119+
- `Send()` remains and delegates to `SendWithContext(context.Background())`;
120+
existing callers are unaffected.
121+
84122
### Transaction Operations
85123

86124
```go

‎jsonrpc2/client_options.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package jsonrpc2
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"net/http"
8+
"time"
9+
10+
"github.com/pkg/errors"
11+
"github.com/steemit/steemutil/protocol/api"
12+
)
13+
14+
// ClientOption configures a JsonRpc client created by NewClientWithOptions.
15+
type ClientOption func(*JsonRpc)
16+
17+
// WithHTTPClient sets the http.Client used to send requests, giving callers
18+
// control over timeouts, transport, and connection pooling. A nil client is
19+
// a no-op: requests then use the legacy fallback — a fresh client with a 30s
20+
// timeout per call, with no connection-reuse guarantees. Inject a client
21+
// when you need pooling or a custom transport, e.g.:
22+
//
23+
// tr := http.DefaultTransport.(*http.Transport).Clone()
24+
// tr.MaxIdleConnsPerHost = 16
25+
// client := jsonrpc2.NewClientWithOptions(url, jsonrpc2.WithHTTPClient(
26+
// &http.Client{Timeout: 15 * time.Second, Transport: tr}))
27+
func WithHTTPClient(c *http.Client) ClientOption {
28+
return func(j *JsonRpc) {
29+
if c == nil {
30+
return
31+
}
32+
j.client = c
33+
}
34+
}
35+
36+
// NewClientWithOptions creates a JsonRpc client configured with opts.
37+
func NewClientWithOptions(url string, opts ...ClientOption) *JsonRpc {
38+
j := &JsonRpc{
39+
Url: url,
40+
}
41+
for _, opt := range opts {
42+
opt(j)
43+
}
44+
return j
45+
}
46+
47+
// Send sends the built request with context.Background(). It is kept for
48+
// backward compatibility; new callers should prefer SendWithContext.
49+
func (j *JsonRpc) Send() (*api.RpcResultData, error) {
50+
return j.SendWithContext(context.Background())
51+
}
52+
53+
// SendWithContext sends the built request carrying ctx, so the caller can
54+
// cancel the in-flight HTTP request or bound its duration. The http.Client
55+
// injected via WithHTTPClient is used when set; otherwise a client with a 30s
56+
// timeout is created per call (the pre-option behavior).
57+
func (j *JsonRpc) SendWithContext(ctx context.Context) (*api.RpcResultData, error) {
58+
bodyReader := bytes.NewReader(j.SendData)
59+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.Url, bodyReader)
60+
if err != nil {
61+
return nil, err
62+
}
63+
req.Header.Set("Content-Type", "application/json")
64+
client := j.client
65+
if client == nil {
66+
// Legacy fallback (pre-option behavior): a fresh 30s-timeout client
67+
// per call. Note this gives no connection-reuse guarantees across
68+
// calls — inject a client via WithHTTPClient when you need pooling
69+
// or a custom transport.
70+
client = &http.Client{
71+
Timeout: 30 * time.Second,
72+
}
73+
}
74+
res, err := client.Do(req)
75+
if err != nil {
76+
return nil, err
77+
}
78+
defer res.Body.Close()
79+
if res.StatusCode != http.StatusOK {
80+
return nil, errors.Errorf("failed to response(http code): %v", res.StatusCode)
81+
}
82+
result := &api.RpcResultData{}
83+
err = json.NewDecoder(res.Body).Decode(result)
84+
return result, err
85+
}

‎jsonrpc2/client_options_test.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package jsonrpc2
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
)
11+
12+
// slowHandler sleeps far longer than any test timeout, so the only way a
13+
// request completes quickly is transport-level cancellation/timeout.
14+
func slowHandler(sleep time.Duration) http.Handler {
15+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
time.Sleep(sleep)
17+
w.WriteHeader(http.StatusOK)
18+
})
19+
}
20+
21+
func TestSendWithContext_CancelAbortsInFlightRequest(t *testing.T) {
22+
srv := httptest.NewServer(slowHandler(5 * time.Second))
23+
defer srv.Close()
24+
25+
client := NewClient(srv.URL)
26+
if err := client.BuildSendData("condenser_api.get_block", []any{1}); err != nil {
27+
t.Fatalf("BuildSendData: %v", err)
28+
}
29+
30+
ctx, cancel := context.WithCancel(context.Background())
31+
go func() {
32+
time.Sleep(100 * time.Millisecond)
33+
cancel()
34+
}()
35+
36+
start := time.Now()
37+
_, err := client.SendWithContext(ctx)
38+
elapsed := time.Since(start)
39+
40+
if err == nil {
41+
t.Fatal("expected error from canceled request, got nil")
42+
}
43+
if !errors.Is(err, context.Canceled) {
44+
t.Errorf("expected context.Canceled in error chain, got: %v", err)
45+
}
46+
// Failure mode without cancellation would be the handler's 5s sleep;
47+
// 2s bounds scheduler/CI noise while still proving cancel won.
48+
if elapsed >= 2*time.Second {
49+
t.Errorf("cancel should abort promptly, took %v", elapsed)
50+
}
51+
}
52+
53+
func TestWithHTTPClient_InjectedTimeoutApplies(t *testing.T) {
54+
srv := httptest.NewServer(slowHandler(5 * time.Second))
55+
defer srv.Close()
56+
57+
injected := &http.Client{Timeout: 100 * time.Millisecond}
58+
client := NewClientWithOptions(srv.URL, WithHTTPClient(injected))
59+
if err := client.BuildSendData("condenser_api.get_block", []any{1}); err != nil {
60+
t.Fatalf("BuildSendData: %v", err)
61+
}
62+
63+
start := time.Now()
64+
_, err := client.Send()
65+
elapsed := time.Since(start)
66+
67+
if err == nil {
68+
t.Fatal("expected timeout error from injected client, got nil")
69+
}
70+
// Failure mode without the injected timeout would be the handler's 5s
71+
// sleep; 2s bounds scheduler/CI noise while still proving the timeout
72+
// fired.
73+
if elapsed >= 2*time.Second {
74+
t.Errorf("injected 100ms timeout should apply, took %v", elapsed)
75+
}
76+
}
77+
78+
// TestWithHTTPClient_NilIsNoOp pins the documented nil semantics: passing a
79+
// nil client leaves the legacy per-call 30s-timeout fallback in place, and
80+
// requests still succeed.
81+
func TestWithHTTPClient_NilIsNoOp(t *testing.T) {
82+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+
w.Header().Set("Content-Type", "application/json")
84+
_, _ = w.Write([]byte(`{"id":1,"jsonrpc":"2.0","result":{"ok":true}}`))
85+
}))
86+
defer srv.Close()
87+
88+
client := NewClientWithOptions(srv.URL, WithHTTPClient(nil))
89+
if client.client != nil {
90+
t.Fatal("WithHTTPClient(nil) must not set a client (no-op, legacy fallback)")
91+
}
92+
if err := client.BuildSendData("condenser_api.get_block", []any{1}); err != nil {
93+
t.Fatalf("BuildSendData: %v", err)
94+
}
95+
res, err := client.SendWithContext(context.Background())
96+
if err != nil {
97+
t.Fatalf("SendWithContext through legacy fallback: %v", err)
98+
}
99+
if res.Result == nil {
100+
t.Error("expected non-nil result")
101+
}
102+
}
103+
104+
func TestWithHTTPClient_DecodesResult(t *testing.T) {
105+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106+
w.Header().Set("Content-Type", "application/json")
107+
// A get_block beyond head returns {"result": null} on real nodes.
108+
_, _ = w.Write([]byte(`{"id":1,"jsonrpc":"2.0","result":null}`))
109+
}))
110+
defer srv.Close()
111+
112+
client := NewClientWithOptions(srv.URL, WithHTTPClient(&http.Client{}))
113+
if err := client.BuildSendData("condenser_api.get_block", []any{111000000}); err != nil {
114+
t.Fatalf("BuildSendData: %v", err)
115+
}
116+
117+
res, err := client.Send()
118+
if err != nil {
119+
t.Fatalf("Send: %v", err)
120+
}
121+
if res.Result != nil {
122+
t.Errorf("expected nil result, got %v", res.Result)
123+
}
124+
}
125+
126+
// TestSendWithContext_LegacyClientShape pins the fallback behavior: without
127+
// WithHTTPClient the request still succeeds through the per-call 30s client.
128+
func TestSendWithContext_LegacyClientShape(t *testing.T) {
129+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130+
w.Header().Set("Content-Type", "application/json")
131+
_, _ = w.Write([]byte(`{"id":1,"jsonrpc":"2.0","result":{"ok":true}}`))
132+
}))
133+
defer srv.Close()
134+
135+
client := NewClient(srv.URL)
136+
if err := client.BuildSendData("condenser_api.get_dynamic_global_properties", []any{}); err != nil {
137+
t.Fatalf("BuildSendData: %v", err)
138+
}
139+
140+
res, err := client.SendWithContext(context.Background())
141+
if err != nil {
142+
t.Fatalf("SendWithContext: %v", err)
143+
}
144+
if res.Result == nil {
145+
t.Error("expected non-nil result")
146+
}
147+
}

‎jsonrpc2/jsonrpc2.go‎

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
package jsonrpc2
22

33
import (
4-
"bytes"
54
"encoding/json"
65
"fmt"
76
"net/http"
87
"os"
9-
"time"
108

11-
"github.com/pkg/errors"
129
"github.com/steemit/steemutil/protocol/api"
1310
)
1411

@@ -19,6 +16,11 @@ type IJsonRpc interface {
1916
type JsonRpc struct {
2017
Url string
2118
SendData []byte
19+
20+
// client is the http.Client used by SendWithContext. A nil client means
21+
// "legacy behavior": a client with a 30s timeout per call. Set it via
22+
// WithHTTPClient / NewClientWithOptions.
23+
client *http.Client
2224
}
2325

2426
func (j *JsonRpc) BuildSendData(method string, params []any) (err error) {
@@ -33,41 +35,15 @@ func (j *JsonRpc) BuildSendData(method string, params []any) (err error) {
3335
return
3436
}
3537
j.SendData = tmp
36-
38+
3739
// Debug: Print JSON request if DEBUG is set
3840
if os.Getenv("DEBUG") != "" && method == "condenser_api.broadcast_transaction_synchronous" {
3941
fmt.Printf("=== JSON-RPC Request ===\n%s\n", string(tmp))
4042
}
41-
42-
return
43-
}
4443

45-
func (j *JsonRpc) Send() (result *api.RpcResultData, err error) {
46-
bodyReader := bytes.NewReader(j.SendData)
47-
req, err := http.NewRequest(http.MethodPost, j.Url, bodyReader)
48-
if err != nil {
49-
return
50-
}
51-
req.Header.Set("Content-Type", "application/json")
52-
client := http.Client{
53-
Timeout: 30 * time.Second,
54-
}
55-
res, err := client.Do(req)
56-
if err != nil {
57-
return
58-
}
59-
defer res.Body.Close()
60-
if res.StatusCode != http.StatusOK {
61-
return result, errors.Errorf("failed to response(http code): %v", res.StatusCode)
62-
}
63-
result = &api.RpcResultData{}
64-
err = json.NewDecoder(res.Body).Decode(result)
6544
return
6645
}
6746

6847
func NewClient(url string) *JsonRpc {
69-
client := &JsonRpc{
70-
Url: url,
71-
}
72-
return client
48+
return NewClientWithOptions(url)
7349
}

0 commit comments

Comments
 (0)