Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion github/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -1212,8 +1212,12 @@ func (s *CopilotService) fetchMetricsReport(ctx context.Context, url string) (*h
return nil, nil, err
}

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

Expand Down
32 changes: 32 additions & 0 deletions github/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3212,6 +3212,38 @@ func TestCopilotService_DownloadDailyMetrics(t *testing.T) {
}
}

// CheckResponse substitutes resp.Body with a re-readable copy on error
// responses; fetchMetricsReport must still close the original body it replaces.
func TestCopilotService_fetchMetricsReport_closesOriginalBodyOnErrorResponse(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

mux.HandleFunc("/path/to/daily", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Bad Request"}`, 400)
})

var closed bool
base := client.client.Transport
if base == nil {
base = http.DefaultTransport
}
client.client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
resp, err := base.RoundTrip(req)
if resp != nil {
resp.Body = &closeRecorder{ReadCloser: resp.Body, closed: &closed}
}
return resp, err
})

ctx := t.Context()
if _, _, err := client.Copilot.DownloadDailyMetrics(ctx, client.baseURL.String()+"path/to/daily"); err == nil {
t.Fatal("Copilot.DownloadDailyMetrics expected error but got none")
}
if !closed {
t.Error("original response body was not closed on an error response")
}
}

func TestCopilotService_DownloadPeriodicMetrics(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)
Expand Down
12 changes: 11 additions & 1 deletion github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -1281,9 +1281,13 @@ func (c *Client) bareDo(caller *http.Client, req *http.Request) (*Response, erro
c.rateMu.Unlock()
}

// CheckResponse substitutes r.Body with a re-readable copy on error
// responses, so capture the network body first: it is the one that must
// be closed.
origBody := resp.Body
Comment on lines +1284 to +1287

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we centralize the fix to one place? I think it's possible to patch only CheckResponse.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The patch is something like this:

diff --git a/github/copilot.go b/github/copilot.go
index adbfae32..9dd24eb8 100644
--- a/github/copilot.go
+++ b/github/copilot.go
@@ -1212,12 +1212,8 @@ func (s *CopilotService) fetchMetricsReport(ctx context.Context, url string) (*h
 		return nil, nil, err
 	}
 
-	// CheckResponse substitutes resp.Body with a re-readable copy on error
-	// responses, so capture the original body first: it is the one that must
-	// be closed.
-	origBody := resp.Body
 	if err := CheckResponse(resp); err != nil {
-		_ = origBody.Close()
+		resp.Body.Close()
 		return nil, newResponse(resp), err
 	}
 
diff --git a/github/copilot_test.go b/github/copilot_test.go
index ee1f804a..4ddbab41 100644
--- a/github/copilot_test.go
+++ b/github/copilot_test.go
@@ -3212,8 +3212,8 @@ func TestCopilotService_DownloadDailyMetrics(t *testing.T) {
 	}
 }
 
-// CheckResponse substitutes resp.Body with a re-readable copy on error
-// responses; fetchMetricsReport must still close the original body it replaces.
+// CheckResponse closes the original resp.Body and substitutes a re-readable
+// copy on error responses; fetchMetricsReport must not leak the original body.
 func TestCopilotService_fetchMetricsReport_closesOriginalBodyOnErrorResponse(t *testing.T) {
 	t.Parallel()
 	client, mux, _ := setup(t)
diff --git a/github/github.go b/github/github.go
index efb4c677..bfa0bd10 100644
--- a/github/github.go
+++ b/github/github.go
@@ -1282,13 +1282,9 @@ func (c *Client) bareDo(caller *http.Client, req *http.Request) (*Response, erro
 		c.rateMu.Unlock()
 	}
 
-	// CheckResponse substitutes r.Body with a re-readable copy on error
-	// responses, so capture the network body first: it is the one that must
-	// be closed.
-	origBody := resp.Body
 	err = CheckResponse(resp)
 	if err != nil {
-		defer origBody.Close()
+		defer resp.Body.Close()
 		// Special case for AcceptedErrors. If an AcceptedError
 		// has been encountered, the response's payload will be
 		// added to the AcceptedError and returned.
@@ -1795,11 +1791,9 @@ func (e *Error) UnmarshalJSON(data []byte) error {
 // API error responses are expected to have response
 // body, and a JSON response body that maps to [ErrorResponse].
 //
-// On error responses other than 202 Accepted, CheckResponse consumes r.Body
-// and replaces it with an in-memory copy so that the error body can be
-// re-read. Closing r.Body after CheckResponse returns therefore closes only
-// the copy: to release the original body and its underlying connection,
-// capture r.Body before the call and close the captured body instead.
+// On error responses other than 202 Accepted, CheckResponse consumes and
+// closes r.Body, then replaces it with an in-memory copy so that the error
+// body can be re-read by the caller.
 //
 // The error type will be *[RateLimitError] for rate limit exceeded errors,
 // *[AcceptedError] for 202 Accepted status codes,
@@ -1822,9 +1816,11 @@ func CheckResponse(r *http.Response) error {
 			errorResponse = &ErrorResponse{Response: r}
 		}
 	}
-	// Re-populate error response body because GitHub error responses are often
-	// undocumented and inconsistent.
+	// Close the original body and re-populate the error response body with an
+	// in-memory copy, because GitHub error responses are often undocumented and
+	// inconsistent and the caller may need to re-read them.
 	// Issue #1136, #540.
+	_ = r.Body.Close()
 	r.Body = io.NopCloser(bytes.NewBuffer(data))
 	switch {
 	case r.StatusCode == http.StatusUnauthorized && strings.HasPrefix(r.Header.Get(headerOTP), "required"):
diff --git a/github/github_test.go b/github/github_test.go
index 776abaf4..77b346c6 100644
--- a/github/github_test.go
+++ b/github/github_test.go
@@ -2308,8 +2308,8 @@ func (r *closeRecorder) Close() error {
 	return r.ReadCloser.Close()
 }
 
-// CheckResponse substitutes resp.Body with a re-readable copy on error
-// responses; the network body it replaces must still be closed.
+// CheckResponse closes the original resp.Body and substitutes a re-readable
+// copy on error responses; the network body must not leak.
 func TestDo_closesOriginalBodyOnErrorResponse(t *testing.T) {
 	t.Parallel()
 	client, mux, _ := setup(t)
diff --git a/github/repos_releases.go b/github/repos_releases.go
index 3361a5d7..889afb6d 100644
--- a/github/repos_releases.go
+++ b/github/repos_releases.go
@@ -375,12 +375,8 @@ func (s *RepositoriesService) downloadReleaseAssetFromURL(ctx context.Context, f
 	if err != nil {
 		return nil, err
 	}
-	// CheckResponse substitutes resp.Body with a re-readable copy on error
-	// responses, so capture the original body first: it is the one that must
-	// be closed.
-	origBody := resp.Body
 	if err := CheckResponse(resp); err != nil {
-		_ = origBody.Close()
+		_ = resp.Body.Close()
 		return nil, err
 	}
 	return resp.Body, nil
diff --git a/github/repos_releases_test.go b/github/repos_releases_test.go
index 4fb85c75..4208513d 100644
--- a/github/repos_releases_test.go
+++ b/github/repos_releases_test.go
@@ -537,9 +537,9 @@ func TestRepositoriesService_DownloadReleaseAsset_FollowRedirectToError(t *testi
 	}
 }
 
-// CheckResponse substitutes resp.Body with a re-readable copy on error
-// responses; downloadReleaseAssetFromURL must still close the original body it
-// replaces. Unlike its sibling tests, the recorder wraps the follow-redirects
+// CheckResponse closes the original resp.Body and substitutes a re-readable
+// copy on error responses; downloadReleaseAssetFromURL must not leak the
+// original body. Unlike its sibling tests, the recorder wraps the follow-redirects
 // client's transport: that client, not the library client, performs the
 // redirected request, so wrapping the library client would only ever observe
 // the first hop's correctly-closed redirect response and never the leak.

@yavorl yavorl Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didn't do it initially is that this is a behaviour change, callers might start seeing errors where there were none before.

Reason being - Response.Body is only guaranteed to be an io.ReadCloser. Custom transports, test doubles, and direct CheckResponse callers routinely supply non-Transport bodies - so the only close semantics a public API may rely on are io.Closer's, which states that a second Close is undefined behaviour. So if someone was diligent about this before and they closed the body themselves, theoretically they can start seeing errors or panics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, if you would still prefer to go with that approach after my argument. I'd be happy to apply the patch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. Thanks for the explanation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the review & thank you for the merge @gmlewis

err = CheckResponse(resp)
if err != nil {
defer resp.Body.Close()
defer origBody.Close()
// Special case for AcceptedErrors. If an AcceptedError
// has been encountered, the response's payload will be
// added to the AcceptedError and returned.
Expand Down Expand Up @@ -1788,6 +1792,12 @@ func (e *Error) UnmarshalJSON(data []byte) error {
// API error responses are expected to have response
// body, and a JSON response body that maps to [ErrorResponse].
//
// On error responses other than 202 Accepted, CheckResponse consumes r.Body
// and replaces it with an in-memory copy so that the error body can be
// re-read. Closing r.Body after CheckResponse returns therefore closes only
// the copy: to release the original body and its underlying connection,
// capture r.Body before the call and close the captured body instead.
//
// The error type will be *[RateLimitError] for rate limit exceeded errors,
// *[AcceptedError] for 202 Accepted status codes,
// *[TwoFactorAuthError] for two-factor authentication errors,
Expand Down
44 changes: 44 additions & 0 deletions github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2296,6 +2296,50 @@ func TestDo_httpError(t *testing.T) {
}
}

// closeRecorder flags when the response body handed back by the transport
// is closed.
type closeRecorder struct {
io.ReadCloser
closed *bool
}

@yavorl yavorl Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having this round tripper is also a precondition for the bug. Not a necessary precondition for the test because we're asserting that the underlying cause can't happen - Close() not being called.


func (r *closeRecorder) Close() error {
*r.closed = true
return r.ReadCloser.Close()
}

// CheckResponse substitutes resp.Body with a re-readable copy on error
// responses; the network body it replaces must still be closed.
func TestDo_closesOriginalBodyOnErrorResponse(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Bad Request"}`, 400)
})

var closed bool
base := client.client.Transport
if base == nil {
base = http.DefaultTransport
}
client.client.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
resp, err := base.RoundTrip(req)
if resp != nil {
resp.Body = &closeRecorder{ReadCloser: resp.Body, closed: &closed}
}
return resp, err
})

req, _ := client.NewRequest(t.Context(), "GET", ".", nil)
if _, err := client.Do(req, nil); err == nil {
t.Fatal("Expected HTTP 400 error, got no error.")
}
if !closed {
t.Error("original response body was not closed on an error response")
}
}

// Test handling of an error caused by the internal http client's Do()
// function. A redirect loop is pretty unlikely to occur within the GitHub
// API, but does allow us to exercise the right code path.
Expand Down
6 changes: 5 additions & 1 deletion github/repos_releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,12 @@ func (s *RepositoriesService) downloadReleaseAssetFromURL(ctx context.Context, f
if err != nil {
return nil, err
}
// CheckResponse substitutes resp.Body with a re-readable copy on error
// responses, so capture the original body first: it is the one that must
// be closed.
origBody := resp.Body
if err := CheckResponse(resp); err != nil {
_ = resp.Body.Close()
_ = origBody.Close()
return nil, err
}
return resp.Body, nil
Expand Down
50 changes: 50 additions & 0 deletions github/repos_releases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,56 @@ func TestRepositoriesService_DownloadReleaseAsset_FollowRedirectToError(t *testi
}
}

// CheckResponse substitutes resp.Body with a re-readable copy on error
// responses; downloadReleaseAssetFromURL must still close the original body it
// replaces. Unlike its sibling tests, the recorder wraps the follow-redirects
// client's transport: that client, not the library client, performs the
// redirected request, so wrapping the library client would only ever observe
// the first hop's correctly-closed redirect response and never the leak.
func TestRepositoriesService_DownloadReleaseAsset_FollowRedirectToErrorClosesOriginalBody(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

mux.HandleFunc("/repos/o/r/releases/assets/1", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testHeader(t, r, "Accept", defaultMediaType)
// /yo, below will be served as baseURLPath/yo
http.Redirect(w, r, baseURLPath+"/yo", http.StatusFound)
})
mux.HandleFunc("/yo", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "GET")
testHeader(t, r, "Accept", defaultMediaType)
http.Error(w, `{"message":"Not Found"}`, 404)
})

var closed bool
followRedirectsClient := &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
resp, err := http.DefaultTransport.RoundTrip(req)
if resp != nil {
resp.Body = &closeRecorder{ReadCloser: resp.Body, closed: &closed}
}
return resp, err
}),
}

ctx := t.Context()
rc, loc, err := client.Repositories.DownloadReleaseAsset(ctx, "o", "r", 1, followRedirectsClient)
if err == nil {
t.Error("Repositories.DownloadReleaseAsset did not return an error")
}
if rc != nil {
rc.Close()
t.Error("Repositories.DownloadReleaseAsset returned stream, want nil")
}
if loc != "" {
t.Errorf(`Repositories.DownloadReleaseAsset returned "%v", want empty ""`, loc)
}
if !closed {
t.Error("original response body was not closed on an error response")
}
}

func TestRepositoriesService_DownloadReleaseAsset_APIError(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)
Expand Down
Loading