diff --git a/github/github.go b/github/github.go index 91af5aa665f..fe388d95bb4 100644 --- a/github/github.go +++ b/github/github.go @@ -554,6 +554,12 @@ func WithVersion(version string) RequestOption { } } +var requestBufferPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + // NewRequest creates an API request. A relative URL can be provided in urlStr, // in which case it is resolved relative to the BaseURL of the Client. // Relative URLs should always be specified without a preceding slash. If @@ -1114,12 +1120,24 @@ func (c *Client) Do(req *http.Request, v any) (*Response, error) { case io.Writer: _, err = io.Copy(v, resp.Body) default: - decErr := json.NewDecoder(resp.Body).Decode(v) - if decErr == io.EOF { - decErr = nil // ignore EOF errors caused by empty response body - } - if decErr != nil { - err = decErr + respBuf := requestBufferPool.Get().(*bytes.Buffer) + defer func() { + respBuf.Reset() + requestBufferPool.Put(respBuf) + }() + + _, readErr := respBuf.ReadFrom(resp.Body) + if readErr != nil { + err = readErr + } else if respBuf.Len() > 0 { + b := respBuf.Bytes() + decErr := json.Unmarshal(b, v) + if decErr != nil && len(bytes.TrimSpace(b)) == 0 { + decErr = nil // ignore errors caused by empty response body + } + if decErr != nil { + err = decErr + } } } return resp, err diff --git a/github/github_benchmark_test.go b/github/github_benchmark_test.go new file mode 100644 index 00000000000..6411e0481ec --- /dev/null +++ b/github/github_benchmark_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// legacyDecodeResponse simulates the behavior before Symmetrical Pooling +// (io.ReadAll -> json.Unmarshal). +func legacyDecodeResponse(resp *http.Response, v any) error { + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if len(data) > 0 { + return json.Unmarshal(data, v) + } + return nil +} + +// pooledDecodeResponse simulates the new behavior with Symmetrical Pooling +// (requestBufferPool -> ReadFrom -> json.Unmarshal). +func pooledDecodeResponse(resp *http.Response, v any) error { + respBuf := requestBufferPool.Get().(*bytes.Buffer) + defer func() { + respBuf.Reset() + requestBufferPool.Put(respBuf) + }() + + _, err := respBuf.ReadFrom(resp.Body) + if err != nil { + return err + } + if respBuf.Len() > 0 { + b := respBuf.Bytes() + return json.Unmarshal(b, v) + } + return nil +} + +type dummyReadCloser struct { + io.Reader +} + +func (d *dummyReadCloser) Close() error { return nil } + +func BenchmarkDecodeResponse_Legacy(b *testing.B) { + payload, _ := json.Marshal(map[string]string{"title": "benchmark_test", "body": strings.Repeat("a", 1024*500)}) // 500KB JSON + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + resp := &http.Response{ + Body: &dummyReadCloser{Reader: bytes.NewReader(payload)}, + } + var v map[string]string + b.StartTimer() + + _ = legacyDecodeResponse(resp, &v) + } +} + +func BenchmarkDecodeResponse_Pooled(b *testing.B) { + payload, _ := json.Marshal(map[string]string{"title": "benchmark_test", "body": strings.Repeat("a", 1024*500)}) // 500KB JSON + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + resp := &http.Response{ + Body: &dummyReadCloser{Reader: bytes.NewReader(payload)}, + } + var v map[string]string + b.StartTimer() + + _ = pooledDecodeResponse(resp, &v) + } +}