Skip to content

Commit c48dbf5

Browse files
authored
fix: Close the original response body on non-2xx responses (#4486)
1 parent da8ff81 commit c48dbf5

6 files changed

Lines changed: 147 additions & 3 deletions

File tree

github/copilot.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1212,8 +1212,12 @@ func (s *CopilotService) fetchMetricsReport(ctx context.Context, url string) (*h
12121212
return nil, nil, err
12131213
}
12141214

1215+
// CheckResponse substitutes resp.Body with a re-readable copy on error
1216+
// responses, so capture the original body first: it is the one that must
1217+
// be closed.
1218+
origBody := resp.Body
12151219
if err := CheckResponse(resp); err != nil {
1216-
resp.Body.Close()
1220+
_ = origBody.Close()
12171221
return nil, newResponse(resp), err
12181222
}
12191223

github/copilot_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3212,6 +3212,38 @@ func TestCopilotService_DownloadDailyMetrics(t *testing.T) {
32123212
}
32133213
}
32143214

3215+
// CheckResponse substitutes resp.Body with a re-readable copy on error
3216+
// responses; fetchMetricsReport must still close the original body it replaces.
3217+
func TestCopilotService_fetchMetricsReport_closesOriginalBodyOnErrorResponse(t *testing.T) {
3218+
t.Parallel()
3219+
client, mux, _ := setup(t)
3220+
3221+
mux.HandleFunc("/path/to/daily", func(w http.ResponseWriter, _ *http.Request) {
3222+
http.Error(w, `{"message":"Bad Request"}`, 400)
3223+
})
3224+
3225+
var closed bool
3226+
base := client.client.Transport
3227+
if base == nil {
3228+
base = http.DefaultTransport
3229+
}
3230+
client.client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
3231+
resp, err := base.RoundTrip(req)
3232+
if resp != nil {
3233+
resp.Body = &closeRecorder{ReadCloser: resp.Body, closed: &closed}
3234+
}
3235+
return resp, err
3236+
})
3237+
3238+
ctx := t.Context()
3239+
if _, _, err := client.Copilot.DownloadDailyMetrics(ctx, client.baseURL.String()+"path/to/daily"); err == nil {
3240+
t.Fatal("Copilot.DownloadDailyMetrics expected error but got none")
3241+
}
3242+
if !closed {
3243+
t.Error("original response body was not closed on an error response")
3244+
}
3245+
}
3246+
32153247
func TestCopilotService_DownloadPeriodicMetrics(t *testing.T) {
32163248
t.Parallel()
32173249
client, mux, _ := setup(t)

github/github.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1305,9 +1305,13 @@ func (c *Client) bareDo(caller *http.Client, req *http.Request) (*Response, erro
13051305
c.rateMu.Unlock()
13061306
}
13071307

1308+
// CheckResponse substitutes r.Body with a re-readable copy on error
1309+
// responses, so capture the network body first: it is the one that must
1310+
// be closed.
1311+
origBody := resp.Body
13081312
err = CheckResponse(resp)
13091313
if err != nil {
1310-
defer resp.Body.Close()
1314+
defer origBody.Close()
13111315
// Special case for AcceptedErrors. If an AcceptedError
13121316
// has been encountered, the response's payload will be
13131317
// added to the AcceptedError and returned.
@@ -1821,6 +1825,12 @@ func (e *Error) UnmarshalJSON(data []byte) error {
18211825
// API error responses are expected to have response
18221826
// body, and a JSON response body that maps to [ErrorResponse].
18231827
//
1828+
// On error responses other than 202 Accepted, CheckResponse consumes r.Body
1829+
// and replaces it with an in-memory copy so that the error body can be
1830+
// re-read. Closing r.Body after CheckResponse returns therefore closes only
1831+
// the copy: to release the original body and its underlying connection,
1832+
// capture r.Body before the call and close the captured body instead.
1833+
//
18241834
// The error type will be *[RateLimitError] for rate limit exceeded errors,
18251835
// *[AcceptedError] for 202 Accepted status codes,
18261836
// *[TwoFactorAuthError] for two-factor authentication errors,

github/github_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2296,6 +2296,50 @@ func TestDo_httpError(t *testing.T) {
22962296
}
22972297
}
22982298

2299+
// closeRecorder flags when the response body handed back by the transport
2300+
// is closed.
2301+
type closeRecorder struct {
2302+
io.ReadCloser
2303+
closed *bool
2304+
}
2305+
2306+
func (r *closeRecorder) Close() error {
2307+
*r.closed = true
2308+
return r.ReadCloser.Close()
2309+
}
2310+
2311+
// CheckResponse substitutes resp.Body with a re-readable copy on error
2312+
// responses; the network body it replaces must still be closed.
2313+
func TestDo_closesOriginalBodyOnErrorResponse(t *testing.T) {
2314+
t.Parallel()
2315+
client, mux, _ := setup(t)
2316+
2317+
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
2318+
http.Error(w, `{"message":"Bad Request"}`, 400)
2319+
})
2320+
2321+
var closed bool
2322+
base := client.client.Transport
2323+
if base == nil {
2324+
base = http.DefaultTransport
2325+
}
2326+
client.client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
2327+
resp, err := base.RoundTrip(req)
2328+
if resp != nil {
2329+
resp.Body = &closeRecorder{ReadCloser: resp.Body, closed: &closed}
2330+
}
2331+
return resp, err
2332+
})
2333+
2334+
req, _ := client.NewRequest(t.Context(), "GET", ".", nil)
2335+
if _, err := client.Do(req, nil); err == nil {
2336+
t.Fatal("Expected HTTP 400 error, got no error.")
2337+
}
2338+
if !closed {
2339+
t.Error("original response body was not closed on an error response")
2340+
}
2341+
}
2342+
22992343
// Test handling of an error caused by the internal http client's Do()
23002344
// function. A redirect loop is pretty unlikely to occur within the GitHub
23012345
// API, but does allow us to exercise the right code path.

github/repos_releases.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,12 @@ func (s *RepositoriesService) downloadReleaseAssetFromURL(ctx context.Context, f
375375
if err != nil {
376376
return nil, err
377377
}
378+
// CheckResponse substitutes resp.Body with a re-readable copy on error
379+
// responses, so capture the original body first: it is the one that must
380+
// be closed.
381+
origBody := resp.Body
378382
if err := CheckResponse(resp); err != nil {
379-
_ = resp.Body.Close()
383+
_ = origBody.Close()
380384
return nil, err
381385
}
382386
return resp.Body, nil

github/repos_releases_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,56 @@ func TestRepositoriesService_DownloadReleaseAsset_FollowRedirectToError(t *testi
537537
}
538538
}
539539

540+
// CheckResponse substitutes resp.Body with a re-readable copy on error
541+
// responses; downloadReleaseAssetFromURL must still close the original body it
542+
// replaces. Unlike its sibling tests, the recorder wraps the follow-redirects
543+
// client's transport: that client, not the library client, performs the
544+
// redirected request, so wrapping the library client would only ever observe
545+
// the first hop's correctly-closed redirect response and never the leak.
546+
func TestRepositoriesService_DownloadReleaseAsset_FollowRedirectToErrorClosesOriginalBody(t *testing.T) {
547+
t.Parallel()
548+
client, mux, _ := setup(t)
549+
550+
mux.HandleFunc("/repos/o/r/releases/assets/1", func(w http.ResponseWriter, r *http.Request) {
551+
testMethod(t, r, "GET")
552+
testHeader(t, r, "Accept", defaultMediaType)
553+
// /yo, below will be served as baseURLPath/yo
554+
http.Redirect(w, r, baseURLPath+"/yo", http.StatusFound)
555+
})
556+
mux.HandleFunc("/yo", func(w http.ResponseWriter, r *http.Request) {
557+
testMethod(t, r, "GET")
558+
testHeader(t, r, "Accept", defaultMediaType)
559+
http.Error(w, `{"message":"Not Found"}`, 404)
560+
})
561+
562+
var closed bool
563+
followRedirectsClient := &http.Client{
564+
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
565+
resp, err := http.DefaultTransport.RoundTrip(req)
566+
if resp != nil {
567+
resp.Body = &closeRecorder{ReadCloser: resp.Body, closed: &closed}
568+
}
569+
return resp, err
570+
}),
571+
}
572+
573+
ctx := t.Context()
574+
rc, loc, err := client.Repositories.DownloadReleaseAsset(ctx, "o", "r", 1, followRedirectsClient)
575+
if err == nil {
576+
t.Error("Repositories.DownloadReleaseAsset did not return an error")
577+
}
578+
if rc != nil {
579+
rc.Close()
580+
t.Error("Repositories.DownloadReleaseAsset returned stream, want nil")
581+
}
582+
if loc != "" {
583+
t.Errorf(`Repositories.DownloadReleaseAsset returned "%v", want empty ""`, loc)
584+
}
585+
if !closed {
586+
t.Error("original response body was not closed on an error response")
587+
}
588+
}
589+
540590
func TestRepositoriesService_DownloadReleaseAsset_APIError(t *testing.T) {
541591
t.Parallel()
542592
client, mux, _ := setup(t)

0 commit comments

Comments
 (0)