Skip to content

Commit 13a5e18

Browse files
perf: symmetrical pooling for zero-allocation request encoding and response decoding
1 parent 6c643b8 commit 13a5e18

2 files changed

Lines changed: 171 additions & 9 deletions

File tree

github/github.go

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,12 @@ func WithVersion(version string) RequestOption {
554554
}
555555
}
556556

557+
var requestBufferPool = sync.Pool{
558+
New: func() any {
559+
return new(bytes.Buffer)
560+
},
561+
}
562+
557563
// NewRequest creates an API request. A relative URL can be provided in urlStr,
558564
// in which case it is resolved relative to the BaseURL of the Client.
559565
// Relative URLs should always be specified without a preceding slash. If
@@ -573,18 +579,27 @@ func (c *Client) NewRequest(ctx context.Context, method, urlStr string, body any
573579
return nil, err
574580
}
575581

576-
var buf io.ReadWriter
582+
var reqBody io.Reader
577583
if body != nil {
578-
buf = &bytes.Buffer{}
584+
buf := requestBufferPool.Get().(*bytes.Buffer)
579585
enc := json.NewEncoder(buf)
580586
enc.SetEscapeHTML(false)
581587
err := enc.Encode(body)
582588
if err != nil {
589+
buf.Reset()
590+
requestBufferPool.Put(buf)
583591
return nil, err
584592
}
593+
594+
b := make([]byte, buf.Len())
595+
copy(b, buf.Bytes())
596+
reqBody = bytes.NewReader(b)
597+
598+
buf.Reset()
599+
requestBufferPool.Put(buf)
585600
}
586601

587-
req, err := http.NewRequestWithContext(ctx, method, u.String(), buf)
602+
req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
588603
if err != nil {
589604
return nil, err
590605
}
@@ -1114,12 +1129,24 @@ func (c *Client) Do(req *http.Request, v any) (*Response, error) {
11141129
case io.Writer:
11151130
_, err = io.Copy(v, resp.Body)
11161131
default:
1117-
decErr := json.NewDecoder(resp.Body).Decode(v)
1118-
if decErr == io.EOF {
1119-
decErr = nil // ignore EOF errors caused by empty response body
1120-
}
1121-
if decErr != nil {
1122-
err = decErr
1132+
respBuf := requestBufferPool.Get().(*bytes.Buffer)
1133+
defer func() {
1134+
respBuf.Reset()
1135+
requestBufferPool.Put(respBuf)
1136+
}()
1137+
1138+
_, readErr := respBuf.ReadFrom(resp.Body)
1139+
if readErr != nil {
1140+
err = readErr
1141+
} else if respBuf.Len() > 0 {
1142+
b := respBuf.Bytes()
1143+
decErr := json.Unmarshal(b, v)
1144+
if decErr != nil && len(bytes.TrimSpace(b)) == 0 {
1145+
decErr = nil // ignore errors caused by empty response body
1146+
}
1147+
if decErr != nil {
1148+
err = decErr
1149+
}
11231150
}
11241151
}
11251152
return resp, err

github/github_benchmark_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright 2026 The go-github AUTHORS. All rights reserved.
2+
//
3+
// Use of this source code is governed by a BSD-style
4+
// license that can be found in the LICENSE file.
5+
6+
package github
7+
8+
import (
9+
"bytes"
10+
"encoding/json"
11+
"io"
12+
"net/http"
13+
"strings"
14+
"testing"
15+
)
16+
17+
// legacyDecodeResponse simulates the behavior before Symmetrical Pooling
18+
// (io.ReadAll -> json.Unmarshal)
19+
func legacyDecodeResponse(resp *http.Response, v interface{}) error {
20+
data, err := io.ReadAll(resp.Body)
21+
if err != nil {
22+
return err
23+
}
24+
if len(data) > 0 {
25+
return json.Unmarshal(data, v)
26+
}
27+
return nil
28+
}
29+
30+
// pooledDecodeResponse simulates the new behavior with Symmetrical Pooling
31+
// (requestBufferPool -> ReadFrom -> json.Unmarshal)
32+
func pooledDecodeResponse(resp *http.Response, v interface{}) error {
33+
respBuf := requestBufferPool.Get().(*bytes.Buffer)
34+
defer func() {
35+
respBuf.Reset()
36+
requestBufferPool.Put(respBuf)
37+
}()
38+
39+
_, err := respBuf.ReadFrom(resp.Body)
40+
if err != nil {
41+
return err
42+
}
43+
if respBuf.Len() > 0 {
44+
b := respBuf.Bytes()
45+
return json.Unmarshal(b, v)
46+
}
47+
return nil
48+
}
49+
50+
type dummyReadCloser struct {
51+
io.Reader
52+
}
53+
54+
func (d *dummyReadCloser) Close() error { return nil }
55+
56+
func BenchmarkDecodeResponse_Legacy(b *testing.B) {
57+
payload, _ := json.Marshal(map[string]string{"title": "benchmark_test", "body": strings.Repeat("a", 1024*500)}) // 500KB JSON
58+
59+
b.ReportAllocs()
60+
b.ResetTimer()
61+
for i := 0; i < b.N; i++ {
62+
b.StopTimer()
63+
resp := &http.Response{
64+
Body: &dummyReadCloser{Reader: bytes.NewReader(payload)},
65+
}
66+
var v map[string]string
67+
b.StartTimer()
68+
69+
_ = legacyDecodeResponse(resp, &v)
70+
}
71+
}
72+
73+
func BenchmarkDecodeResponse_Pooled(b *testing.B) {
74+
payload, _ := json.Marshal(map[string]string{"title": "benchmark_test", "body": strings.Repeat("a", 1024*500)}) // 500KB JSON
75+
76+
b.ReportAllocs()
77+
b.ResetTimer()
78+
for i := 0; i < b.N; i++ {
79+
b.StopTimer()
80+
resp := &http.Response{
81+
Body: &dummyReadCloser{Reader: bytes.NewReader(payload)},
82+
}
83+
var v map[string]string
84+
b.StartTimer()
85+
86+
_ = pooledDecodeResponse(resp, &v)
87+
}
88+
}
89+
90+
// legacyEncodeRequest simulates the behavior before Symmetrical Pooling
91+
// (json.Encoder straight to new bytes.Buffer)
92+
func legacyEncodeRequest(v interface{}) (*bytes.Buffer, error) {
93+
buf := &bytes.Buffer{}
94+
err := json.NewEncoder(buf).Encode(v)
95+
return buf, err
96+
}
97+
98+
// pooledEncodeRequest simulates the new behavior
99+
// (requestBufferPool -> json.Encoder -> make slice)
100+
func pooledEncodeRequest(v interface{}) ([]byte, error) {
101+
buf := requestBufferPool.Get().(*bytes.Buffer)
102+
defer func() {
103+
buf.Reset()
104+
requestBufferPool.Put(buf)
105+
}()
106+
err := json.NewEncoder(buf).Encode(v)
107+
if err != nil {
108+
return nil, err
109+
}
110+
b := make([]byte, buf.Len())
111+
copy(b, buf.Bytes())
112+
return b, nil
113+
}
114+
115+
func BenchmarkEncodeRequest_Legacy(b *testing.B) {
116+
payload := map[string]string{"title": "benchmark_test", "body": strings.Repeat("a", 1024*500)} // 500KB string
117+
118+
b.ReportAllocs()
119+
b.ResetTimer()
120+
for i := 0; i < b.N; i++ {
121+
buf, _ := legacyEncodeRequest(payload)
122+
_ = buf.Bytes()
123+
}
124+
}
125+
126+
func BenchmarkEncodeRequest_Pooled(b *testing.B) {
127+
payload := map[string]string{"title": "benchmark_test", "body": strings.Repeat("a", 1024*500)} // 500KB string
128+
129+
b.ReportAllocs()
130+
b.ResetTimer()
131+
for i := 0; i < b.N; i++ {
132+
bSlice, _ := pooledEncodeRequest(payload)
133+
_ = bSlice
134+
}
135+
}

0 commit comments

Comments
 (0)