Compress data on flush when compression is not started

Co-authored-by: Romain <rtribotte@users.noreply.github.com>
This commit is contained in:
Kevin Pollet 2025-03-07 16:16:04 +01:00 committed by GitHub
parent 07e6491ace
commit 474ab23fe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 185 additions and 79 deletions

View file

@ -179,9 +179,15 @@ http:
_Optional, Default=1024_
`minResponseBodyBytes` specifies the minimum amount of bytes a response body must have to be compressed.
Responses smaller than the specified values will not be compressed.
!!! tip "Streaming"
When data is sent to the client on flush, the `minResponseBodyBytes` configuration is ignored and the data is compressed.
This is particularly the case when data is streamed to the client when using `Transfer-encoding: chunked` response.
When chunked data is sent to the client on flush, it will be compressed by default even if the received data has not reached
```yaml tab="Docker & Swarm"
labels:
- "traefik.http.middlewares.test-compress.compress.minresponsebodybytes=1200"

View file

@ -609,6 +609,27 @@ func TestMinResponseBodyBytes(t *testing.T) {
func Test1xxResponses(t *testing.T) {
fakeBody := generateBytes(100000)
testCases := []struct {
desc string
encoding string
}{
{
desc: "gzip",
encoding: gzipName,
},
{
desc: "brotli",
encoding: brotliName,
},
{
desc: "zstd",
encoding: zstdName,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Add("Link", "</style.css>; rel=preload; as=style")
@ -671,7 +692,7 @@ func Test1xxResponses(t *testing.T) {
},
}
req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
req.Header.Add(acceptEncodingHeader, gzipName)
req.Header.Add(acceptEncodingHeader, test.encoding)
res, err := frontendClient.Do(req)
assert.NoError(t, err)
@ -683,9 +704,11 @@ func Test1xxResponses(t *testing.T) {
}
checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
assert.Equal(t, gzipName, res.Header.Get(contentEncodingHeader))
assert.Equal(t, test.encoding, res.Header.Get(contentEncodingHeader))
body, _ := io.ReadAll(res.Body)
assert.NotEqualValues(t, body, fakeBody)
})
}
}
func BenchmarkCompressGzip(b *testing.B) {

View file

@ -192,13 +192,18 @@ func (r *responseWriter) Header() http.Header {
}
func (r *responseWriter) WriteHeader(statusCode int) {
if r.statusCodeSet {
// Handle informational headers
// This is gated to not forward 1xx responses on builds prior to go1.20.
if statusCode >= 100 && statusCode <= 199 {
r.rw.WriteHeader(statusCode)
return
}
if !r.statusCodeSet {
r.statusCode = statusCode
r.statusCodeSet = true
}
}
func (r *responseWriter) Write(p []byte) (int, error) {
// i.e. has write ever been called at least once with non nil data.
@ -319,11 +324,16 @@ func (r *responseWriter) Flush() {
}
// Here, nothing was ever written either to rw or to bw (since we're still
// waiting to decide whether to compress), so we do not need to flush anything.
// Note that we diverge with klauspost's gzip behavior, where they instead
// force compression and flush whatever was in the buffer in this case.
// waiting to decide whether to compress), so to be aligned with klauspost's
// gzip behavior we force the compression and flush whatever was in the buffer in this case.
if !r.compressionStarted {
return
r.rw.Header().Del(contentLength)
r.rw.Header().Set(contentEncoding, r.compressionWriter.ContentEncoding())
r.rw.WriteHeader(r.statusCode)
r.headersSent = true
r.compressionStarted = true
}
// Conversely, we here know that something was already written to bw (or is

View file

@ -498,6 +498,73 @@ func Test_FlushAfterAllWrites(t *testing.T) {
}
}
func Test_FlushForceCompress(t *testing.T) {
testCases := []struct {
desc string
cfg Config
algo string
readerBuilder func(io.Reader) (io.Reader, error)
acceptEncoding string
}{
{
desc: "brotli",
cfg: Config{MinSize: 1024, MiddlewareName: "Test"},
algo: brotliName,
readerBuilder: func(reader io.Reader) (io.Reader, error) {
return brotli.NewReader(reader), nil
},
acceptEncoding: "br",
},
{
desc: "zstd",
cfg: Config{MinSize: 1024, MiddlewareName: "Test"},
algo: zstdName,
readerBuilder: func(reader io.Reader) (io.Reader, error) {
return zstd.NewReader(reader)
},
acceptEncoding: "zstd",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
_, err := rw.Write(smallTestBody)
require.NoError(t, err)
rw.(http.Flusher).Flush()
})
srv := httptest.NewServer(mustNewCompressionHandler(t, test.cfg, test.algo, next))
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL, http.NoBody)
require.NoError(t, err)
req.Header.Set(acceptEncoding, test.acceptEncoding)
res, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, test.acceptEncoding, res.Header.Get(contentEncoding))
reader, err := test.readerBuilder(res.Body)
require.NoError(t, err)
got, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, smallTestBody, got)
})
}
}
func Test_ExcludedContentTypes(t *testing.T) {
testCases := []struct {
desc string