From 27e69f561b0a263e716cbb8678d30759d70d552f Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 21:33:10 +0000 Subject: [PATCH] fix(http): skip decoding metadata-only responses --- internal/client/client.go | 27 +++++++++++++++++- internal/client/client_test.go | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/internal/client/client.go b/internal/client/client.go index be1b67da..41e4ca00 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2128,7 +2128,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { // Decode only the encodings permitted by the request's compression mode. // Keep this outside net/http's transport so all HTTP versions use the same // streaming decoders and malformed encodings produce the same error. - if resp.Body != nil { + if responseCanHaveBody(resp) { if policy, ok := responseEncodingPolicyFromRequest(req); ok { if !policy.enabled { return resp, nil @@ -2156,6 +2156,31 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { return resp, nil } +// responseCanHaveBody reports whether HTTP semantics permit a response body. +// Servers commonly include representation metadata such as Content-Encoding +// on HEAD and 304 responses even though no encoded bytes follow. Constructing +// a streaming decoder for those responses would incorrectly turn valid +// metadata-only responses into errors such as "gzip: EOF". +func responseCanHaveBody(resp *http.Response) bool { + if resp == nil || resp.Body == nil || resp.Body == http.NoBody { + return false + } + if resp.Request != nil { + switch resp.Request.Method { + case http.MethodHead: + return false + case http.MethodConnect: + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return false + } + } + } + return resp.StatusCode >= http.StatusOK && + resp.StatusCode != http.StatusNoContent && + resp.StatusCode != http.StatusResetContent && + resp.StatusCode != http.StatusNotModified +} + type responseBodyDecoder func(io.ReadCloser) (io.ReadCloser, error) type namedResponseBodyDecoder struct { diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 84328d79..faa94911 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -2690,6 +2690,57 @@ func TestDoOnlyDecodesAllowedCompression(t *testing.T) { } } +func TestDoDoesNotDecodeMetadataOnlyResponses(t *testing.T) { + tests := []struct { + name string + method string + statusCode int + }{ + {name: "HEAD", method: http.MethodHead, statusCode: http.StatusOK}, + {name: "successful CONNECT", method: http.MethodConnect, statusCode: http.StatusOK}, + {name: "informational", method: http.MethodGet, statusCode: http.StatusSwitchingProtocols}, + {name: "no content", method: http.MethodGet, statusCode: http.StatusNoContent}, + {name: "reset content", method: http.MethodGet, statusCode: http.StatusResetContent}, + {name: "not modified", method: http.MethodGet, statusCode: http.StatusNotModified}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closed := false + c := &Client{c: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: tt.statusCode, + ContentLength: 128, + Header: http.Header{"Content-Encoding": {"gzip"}}, + Body: &closeFlagReadCloser{Reader: bytes.NewReader(nil), closedPtr: &closed}, + Request: req, + }, nil + })}} + req, err := c.NewRequest(context.Background(), RequestConfig{ + Compression: core.CompressionGzip, + Method: tt.method, + URL: mustURL(t, "https://example.com"), + }) + if err != nil { + t.Fatal(err) + } + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do returned an error for a response without a body: %v", err) + } + if resp.ContentLength != 128 || WireContentLength(resp) != 128 { + t.Fatalf("content lengths = %d and %d, want encoded metadata length 128", resp.ContentLength, WireContentLength(resp)) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + if !closed { + t.Fatal("response body was not closed") + } + }) + } +} + func TestDoRejectsDisallowedAndMalformedContentEncoding(t *testing.T) { tests := []struct { name string