Repository: traefik/traefik (language: go)

Issue:
Regression - Do not compress when content type text/event-stream
### Welcome!

- [x] Yes, I've searched similar issues on [GitHub](https://github.com/traefik/traefik/issues) and didn't find any.
- [x] Yes, I've searched similar issues on the [Traefik community forum](https://community.traefik.io) and didn't find any.

### What did you do?

I've just upgraded from v2.11 to v3.3 and all request with content type`text/event-stream` are broken because of enabled compression.

It wasn't part of any release notes/migration guide, wasn't marked as a breaking change, so I believe it's a regression, since it [was already part of Traefik since 2019](https://github.com/traefik/traefik/pull/5120).

### What did you see instead?

Had to manually exclude it from compression to fix the problem.

### What version of Traefik are you using?

v3.3.3

### What is your environment & configuration?

```yaml
networks:
  coolify:
    external: true
services:
  traefik:
    container_name: coolify-proxy
    image: 'traefik:v3.3'
    restart: unless-stopped
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    networks:
      - coolify
    ports:
      - '80:80'
      - '443:443'
      - '443:443/udp'
      - '8080:8080'
    healthcheck:
      test: 'wget -qO- http://localhost:80/ping || exit 1'
      interval: 4s
      timeout: 2s
      retries: 5
    volumes:
      - '/var/run/docker.sock:/var/run/docker.sock:ro'
      - '/data/coolify/proxy:/traefik'
    command:
      - '--ping=true'
      - '--ping.entrypoint=http'
      - '--api.dashboard=true'
      - '--api.insecure=false'
      - '--entrypoints.http.address=:80'
      - '--entrypoints.https.address=:443'
      - '--entrypoints.http.http.encodequerysemicolons=true'
      - '--entryPoints.https.http3'
      - '--entrypoints.https.http.encodequerysemicolons=true'
      - '--entryPoints.https.http3'
      - '--providers.file.directory=/traefik/dynamic/'
      - '--providers.file.watch=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http'
      - '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'
      - '--api.insecure=false'
      - '--providers.docker=true'
      - '--providers.docker.exposedbydefault=false'
    labels:
      - traefik.enable=true
      - traefik.http.routers.traefik.entrypoints=http
      - traefik.http.routers.traefik.service=api@internal
      - traefik.http.services.traefik.loadbalancer.server.port=8080
      - coolify.managed=true
      - coolify.proxy=true
```

It's just a generic Coolify instance, nothing crazy, and not really related to the core of the issue.


### If applicable, please paste the log output in DEBUG level

_No response_


Discussion / hints:
Hello @Igloczek and thanks for opening this,

The pull request https://github.com/traefik/traefik/pull/5120 has been closed in favor of https://github.com/traefik/traefik/pull/5721 which introduces an [`ExcludedContentTypes`](https://doc.traefik.io/traefik/middlewares/http/compress/#excludedcontenttypes) option allowing to not compress `text/event-stream` responses.

Maybe we missed something but in v3.3 the [code](https://github.com/traefik/traefik/blob/v3.3/pkg/middlewares/compress/compress.go#L132) is the same, configuring the `excludedContentTypes` option should allow to not compress `text/event-stream` responses. 

Does it fix your issue?


Hi, thanks for the reply.

I misread the status of #5120 and got that #5721 allows users to manually exclude `text/event-stream` from compression, this is how I patched my instance.

However, there is a logic for gRPC that does what needs to be done, so adding SSE to that same list is a simple, non-breaking change, that is much better than forcing everyone to manually configure it.

It's not clear to me why v2.x handled SSE without extra config, but I believe v3.x should work the same way out of the box, and having a way to fix it manually, doesn't actually fix the underlying problem.
As explained in https://github.com/traefik/traefik/pull/11511, we were unable to reproduce the issue with v3.3 and v2.11.
Could you please give us a minimal reproducible use case?
@kevinpollet here you go https://github.com/Igloczek/traefik-sse-debug


Hello @Igloczek and thanks for the reproducing use case,

This seems to be related to the new compression algorithms supported in v3 (Brotli and Zstandard), which are now enabled by default. In v3, enabling only Gzip fixes the issue and everything is working as expected by adding the following label to your docker-compose `"traefik.http.middlewares.gzip.compress.encodings=gzip"`. Check out the [encodings](
https://doc.traefik.io/traefik/middlewares/http/compress/#encodings) documentation for more details.

We are investigating the issue to provide a fix.

Fix patch:
diff --git a/docs/content/middlewares/http/compress.md b/docs/content/middlewares/http/compress.md
index 46028ee0b1..1f204be296 100644
--- a/docs/content/middlewares/http/compress.md
+++ b/docs/content/middlewares/http/compress.md
@@ -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"
diff --git a/pkg/middlewares/compress/compression_handler.go b/pkg/middlewares/compress/compression_handler.go
index 1c5065ca72..215607ccde 100644
--- a/pkg/middlewares/compress/compression_handler.go
+++ b/pkg/middlewares/compress/compression_handler.go
@@ -192,12 +192,17 @@ 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
 	}
 
-	r.statusCode = statusCode
-	r.statusCodeSet = true
+	if !r.statusCodeSet {
+		r.statusCode = statusCode
+		r.statusCodeSet = true
+	}
 }
 
 func (r *responseWriter) Write(p []byte) (int, error) {
@@ -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


Test patch:
diff --git a/pkg/middlewares/compress/compress_test.go b/pkg/middlewares/compress/compress_test.go
index 8279165ac3..430df76119 100644
--- a/pkg/middlewares/compress/compress_test.go
+++ b/pkg/middlewares/compress/compress_test.go
@@ -609,83 +609,106 @@ func TestMinResponseBodyBytes(t *testing.T) {
 func Test1xxResponses(t *testing.T) {
 	fakeBody := generateBytes(100000)
 
-	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		h := w.Header()
-		h.Add("Link", "</style.css>; rel=preload; as=style")
-		h.Add("Link", "</script.js>; rel=preload; as=script")
-		w.WriteHeader(http.StatusEarlyHints)
+	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()
 
-		h.Add("Link", "</foo.js>; rel=preload; as=script")
-		w.WriteHeader(http.StatusProcessing)
+			next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				h := w.Header()
+				h.Add("Link", "</style.css>; rel=preload; as=style")
+				h.Add("Link", "</script.js>; rel=preload; as=script")
+				w.WriteHeader(http.StatusEarlyHints)
 
-		if _, err := w.Write(fakeBody); err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-		}
-	})
-	cfg := dynamic.Compress{
-		MinResponseBodyBytes: 1024,
-		Encodings:            defaultSupportedEncodings,
-	}
-	compress, err := New(context.Background(), next, cfg, "testing")
-	require.NoError(t, err)
+				h.Add("Link", "</foo.js>; rel=preload; as=script")
+				w.WriteHeader(http.StatusProcessing)
 
-	server := httptest.NewServer(compress)
-	t.Cleanup(server.Close)
-	frontendClient := server.Client()
+				if _, err := w.Write(fakeBody); err != nil {
+					http.Error(w, err.Error(), http.StatusInternalServerError)
+				}
+			})
+			cfg := dynamic.Compress{
+				MinResponseBodyBytes: 1024,
+				Encodings:            defaultSupportedEncodings,
+			}
+			compress, err := New(context.Background(), next, cfg, "testing")
+			require.NoError(t, err)
 
-	checkLinkHeaders := func(t *testing.T, expected, got []string) {
-		t.Helper()
+			server := httptest.NewServer(compress)
+			t.Cleanup(server.Close)
+			frontendClient := server.Client()
 
-		if len(expected) != len(got) {
-			t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
-		}
+			checkLinkHeaders := func(t *testing.T, expected, got []string) {
+				t.Helper()
 
-		for i := range expected {
-			if i >= len(got) {
-				t.Errorf("Expected %q link header; got nothing", expected[i])
+				if len(expected) != len(got) {
+					t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
+				}
 
-				continue
-			}
+				for i := range expected {
+					if i >= len(got) {
+						t.Errorf("Expected %q link header; got nothing", expected[i])
 
-			if expected[i] != got[i] {
-				t.Errorf("Expected %q link header; got %q", expected[i], got[i])
-			}
-		}
-	}
+						continue
+					}
 
-	var respCounter uint8
-	trace := &httptrace.ClientTrace{
-		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
-			switch code {
-			case http.StatusEarlyHints:
-				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
-			case http.StatusProcessing:
-				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
-			default:
-				t.Error("Unexpected 1xx response")
+					if expected[i] != got[i] {
+						t.Errorf("Expected %q link header; got %q", expected[i], got[i])
+					}
+				}
 			}
 
-			respCounter++
+			var respCounter uint8
+			trace := &httptrace.ClientTrace{
+				Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+					switch code {
+					case http.StatusEarlyHints:
+						checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
+					case http.StatusProcessing:
+						checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
+					default:
+						t.Error("Unexpected 1xx response")
+					}
 
-			return nil
-		},
-	}
-	req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
-	req.Header.Add(acceptEncodingHeader, gzipName)
+					respCounter++
+
+					return nil
+				},
+			}
+			req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
+			req.Header.Add(acceptEncodingHeader, test.encoding)
 
-	res, err := frontendClient.Do(req)
-	assert.NoError(t, err)
+			res, err := frontendClient.Do(req)
+			assert.NoError(t, err)
 
-	defer res.Body.Close()
+			defer res.Body.Close()
 
-	if respCounter != 2 {
-		t.Errorf("Expected 2 1xx responses; got %d", respCounter)
-	}
-	checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
+			if respCounter != 2 {
+				t.Errorf("Expected 2 1xx responses; got %d", respCounter)
+			}
+			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))
-	body, _ := io.ReadAll(res.Body)
-	assert.NotEqualValues(t, body, fakeBody)
+			assert.Equal(t, test.encoding, res.Header.Get(contentEncodingHeader))
+			body, _ := io.ReadAll(res.Body)
+			assert.NotEqualValues(t, body, fakeBody)
+		})
+	}
 }
 
 func BenchmarkCompressGzip(b *testing.B) {
diff --git a/pkg/middlewares/compress/compression_handler_test.go b/pkg/middlewares/compress/compression_handler_test.go
index c702500d71..b078ed71f3 100644
--- a/pkg/middlewares/compress/compression_handler_test.go
+++ b/pkg/middlewares/compress/compression_handler_test.go
@@ -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