From 35f20b3c84c22575a9237a25b919b280aa02a431 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:47:35 +0000 Subject: [PATCH] Make HTTP/2 response body close idempotent Treat a response body already closed by the caller as a no-op so repeated Close calls do not emit additional RST_STREAM frames. --- http2/transport.go | 6 ++- http2/transport_test.go | 88 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/http2/transport.go b/http2/transport.go index 8fe0ac71..c887eee7 100644 --- a/http2/transport.go +++ b/http2/transport.go @@ -2518,7 +2518,11 @@ func (b transportResponseBody) Close() error { cs := b.cs cc := cs.cc - serverSentStreamEnd := cs.bufPipe.Err() == io.EOF + pipeErr := cs.bufPipe.Err() + if pipeErr == errClosedResponseBody { + return nil + } + serverSentStreamEnd := pipeErr == io.EOF unread := cs.bufPipe.Len() if unread > 0 || !serverSentStreamEnd { diff --git a/http2/transport_test.go b/http2/transport_test.go index 38f3fb6e..b743b2b2 100644 --- a/http2/transport_test.go +++ b/http2/transport_test.go @@ -4088,6 +4088,94 @@ func TestTransportAllocationsAfterResponseBodyClose(t *testing.T) { } } +func TestTransportResponseBodyCloseIsIdempotent(t *testing.T) { + for _, test := range []struct { + name string + endStream bool + }{ + {name: "unfinished"}, + {name: "after EOF", endStream: true}, + } { + t.Run(test.name, func(t *testing.T) { + ct := newClientTester(t) + ct.client = func() error { + req, err := http.NewRequest("GET", "https://dummy.tld/", nil) + if err != nil { + return err + } + res, err := ct.tr.RoundTrip(req) + if err != nil { + return err + } + if test.endStream { + if _, err := io.ReadAll(res.Body); err != nil { + return err + } + } + if err := res.Body.Close(); err != nil { + return err + } + return res.Body.Close() + } + ct.server = func() error { + ct.greet() + hf, err := ct.firstHeaders() + if err != nil { + return err + } + + var buf bytes.Buffer + enc := hpack.NewEncoder(&buf) + enc.WriteField(hpack.HeaderField{Name: ":status", Value: "200"}) + if err := ct.fr.WriteHeaders(HeadersFrameParam{ + StreamID: hf.StreamID, + EndHeaders: true, + BlockFragment: buf.Bytes(), + }); err != nil { + return err + } + if test.endStream { + if err := ct.fr.WriteData(hf.StreamID, true, []byte("body")); err != nil { + return err + } + } + + ct.sc.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + resets := 0 + for { + f, err := ct.fr.ReadFrame() + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + break + } + return fmt.Errorf("reading client frames: %v", err) + } + switch f := f.(type) { + case *SettingsFrame, *WindowUpdateFrame: + continue + case *RSTStreamFrame: + if f.StreamID != hf.StreamID || f.ErrCode != ErrCodeCancel { + return fmt.Errorf("unexpected RST_STREAM: %v", summarizeFrame(f)) + } + resets++ + default: + return fmt.Errorf("got unexpected client frame %T", f) + } + } + wantResets := 1 + if test.endStream { + wantResets = 0 + } + if resets != wantResets { + return fmt.Errorf("received %d RST_STREAM frames; want %d", resets, wantResets) + } + return nil + } + ct.run() + }) + } +} + // Issue 18891: make sure Request.Body == NoBody means no DATA frame // is ever sent, even if empty. func TestTransportNoBodyMeansNoDATA(t *testing.T) {