Repository: litestar-org/litestar

Issue:
Bug: Enabling gzip only sends large streaming responses
### Description

Enabling gzip compression causes small streaming responses to be completely ignored and never sent to the client browser.
I have verified that the request headers to specify at the very least:
`Accept-Encoding: gzip`
As can be seen in the logs.

### URL to code causing the issue

https://github.com/LeckerenSirupwaffeln/Litestar_StreamingResponseGzip_Bug

### MCVE

```python
https://github.com/LeckerenSirupwaffeln/Litestar_StreamingResponseGzip_Bug
```


### Steps to reproduce

```bash
1. git clone https://github.com/LeckerenSirupwaffeln/Litestar_StreamingResponseGzip_Bug
2. cd Litestar_StreamingResponseGzip_Bug
3. docker compose up
4. Open your browser
5. Navigate to http://127.0.0.1:8000/event
6. Open developer tools, go to Console
7. Click "Start retrieving data"
8. Observe that only the large arrays of data get received by the client browser. These arrays should have a size of 1000000, while arrays that have a size of 500 do not get printed to the console, and thus do not get received.

Alternatively:
Disable gzip compression in app construction and observe that arrays of size of 500 do get printed to the console as do the arrays of size of 10000000.
```


### Screenshots

_No response_

### Logs

```bash
Request headers:

POST /event HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9,nl-NL;q=0.8,nl;q=0.7
Connection: keep-alive
Content-Length: 2
Content-Type: application/json
DNT: 1
Host: 127.0.0.1:8000
Origin: http://127.0.0.1:8000
Referer: http://127.0.0.1:8000/event
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="113", "Chromium";v="113", "Not-A.Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
```


### Litestar Version

2.0.0alpha3

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)


Discussion:
Thanks for the report @LeckerenSirupwaffeln 

When I run `docker compose up` on your example repo I get:

```
failed to solve: process "/bin/sh -c apt-get update && apt-get upgrade && apt-get install -y --no-install-recommends sudo && apt-get install -y --no-install-recommends bash && apt-get install -y --no-install-recommends git && apt-get install -y --no-install-recommends vim && apt-get purge -y --auto-remove && rm -rf /var/lib/apt/lists/*" did not complete successfully: exit code: 1
```

Any tips on what I might be doing wrong?
Hi, peterschutt
1) It is possible that it prompted you on apt-get update && apt-get upgrade, so I made a small commit to include the -y flag this time. Please try again with the new commit.
2) You could have CRLF line-endings due to git. Verify that the line-endings in the file are all LF. 
3) Further I am not sure why this error would occur, I do not have it on my system. 

@LeckerenSirupwaffeln do you think it would be possible to create a self contained MCVE that does not require docker etc.?

That would make testing on our end a lot easier.
I cannot reproduce this with:


```python
from litestar import get, Litestar
from litestar.config.compression import CompressionConfig
from litestar.testing import TestClient


@get(path="/", sync_to_thread=False)
def handler() -> str:
    return "foo"


app = Litestar(
    route_handlers=[handler],
    compression_config=CompressionConfig(
        backend="gzip",
        minimum_size=1,
    ),
)

with TestClient(app=app) as client:
    res = client.get("/")
    assert res.headers.get("content-encoding") == "gzip"

```

Are you sure you have set `minimum_size` to something other than the default value?
Hi provinzkraut, 

1) I have indeed tried to set a different `minimum_size`, I also do not think the default `minimum_size` should cause this bug.
2) Sorry for the non-working Docker, here is the MCVE, took me a while to make because I could not replicate the bug exactly either, but eventually I did:

```
from litestar import Litestar, MediaType, post
from litestar.config.compression import CompressionConfig
from litestar.response_containers import Stream
from litestar.serialization import encode_json

from asyncio import sleep
from pathlib import Path


GLOBAL_SMALL_DICT = {}
for i in range(500):
  GLOBAL_SMALL_DICT[str(i)] = i
  
GLOBAL_LARGE_DICT = {}
for i in range(1000000):
  GLOBAL_LARGE_DICT[str(i)] = i

class SmallDataStreamer:
  def __aiter__(self) -> "DataStreamer":
    return self
  
  async def __anext__(self) -> bytes:
    await sleep(1)
    return encode_json(GLOBAL_SMALL_DICT)
  
class LargeDataStreamer:
  def __aiter__(self) -> "DataStreamer":
    return self
  
  async def __anext__(self) -> bytes:
    await sleep(1)
    return encode_json(GLOBAL_LARGE_DICT)

@post("/raw_small_example", media_type=MediaType.JSON) # Always works.
async def raw_small_example(data: dict[str, str]) -> dict[str, str]:
  return GLOBAL_SMALL_DICT

@post("/raw_large_example", media_type=MediaType.JSON) # Always works.
async def raw_large_example(data: dict[str, str]) -> dict[str, str]:
  return GLOBAL_LARGE_DICT

@post("/stream_small_example", media_type=MediaType.JSON) # Only works when gzip is disabled.
async def stream_small_example(data: dict[str, str]) -> Stream:
  small_data_streamer = SmallDataStreamer()
  return Stream(iterator=small_data_streamer)

@post("/stream_large_example", media_type=MediaType.JSON) # Always works.
async def stream_large_example(data: dict[str, str]) -> Stream:
  large_data_streamer = LargeDataStreamer()
  return Stream(iterator=large_data_streamer)

app = Litestar(
  route_handlers=[raw_small_example, raw_large_example, stream_small_example, stream_large_example],
  compression_config=CompressionConfig(backend="gzip", gzip_compress_level=9),
)
```

How to use this MCVE:

1. Set it up to be running locally.
2. `curl -X POST -H 'Accept-encoding: gzip' --data '{\"data\": \"\"}' 127.0.0.1:8000/raw_large_example --compressed`
3. `curl -X POST -H 'Accept-encoding: gzip' --data '{\"data\": \"\"}' 127.0.0.1:8000/stream_large_example --compressed`
4. After getting the data once: CTRL+C
5. `curl -X POST -H 'Accept-encoding: gzip' --data '{\"data\": \"\"}' 127.0.0.1:8000/raw_small_example --compressed`
6. `curl -X POST -H 'Accept-encoding: gzip' --data '{\"data\": \"\"}' 127.0.0.1:8000/stream_small_example --compressed`
7. Notice you never get the data within a reasonable time after executing step 6.
8. Now comment-out gzip compression and repeat steps 1 to 7, now you do get the data within a reasonable time after step 6.

The bug seems to be an interaction between `Stream` and `gzip compression`, when `gzip compression` is enabled, only a very large `StreamingResponse` gets sent.

Interestingly, when first sending a small `StreamingResponse` followed a second later by a large `StreamingResponse`, you will receive both at once in-order.

Resolving patch:
diff --git a/litestar/middleware/compression.py b/litestar/middleware/compression.py
--- a/litestar/middleware/compression.py
+++ b/litestar/middleware/compression.py
@@ -85,6 +85,7 @@ def write(self, body: bytes) -> None:
             self.buffer.write(self.compressor.process(body) + self.compressor.flush())  # type: ignore
         else:
             self.compressor.write(body)
+            self.compressor.flush()
 
     def close(self) -> None:
         """Close the compression stream.


Test patch:
diff --git a/litestar/contrib/repository/testing/generic_mock_repository.py b/litestar/contrib/repository/testing/generic_mock_repository.py
--- a/litestar/contrib/repository/testing/generic_mock_repository.py
+++ b/litestar/contrib/repository/testing/generic_mock_repository.py
@@ -728,5 +728,5 @@ def clear_collection(cls) -> None:
         cls.collection = {}
 
 
-def model_items(model: ModelT) -> list[tuple[str, Any]]:
+def model_items(model: Any) -> list[tuple[str, Any]]:
     return [(k, v) for k, v in model.__dict__.items() if not k.startswith("_")]
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -64,6 +64,7 @@
     from litestar import Litestar
     from litestar.types import (
         AnyIOBackend,
+        ASGIApp,
         ASGIVersion,
         Receive,
         RouteHandlerType,
@@ -88,8 +89,12 @@ def anyio_backend(request: pytest.FixtureRequest) -> str:
     return request.param  # type: ignore[no-any-return]
 
 
-async def mock_asgi_app(scope: Scope, receive: Receive, send: Send) -> None:
-    pass
+@pytest.fixture()
+def mock_asgi_app() -> ASGIApp:
+    async def asgi_app(scope: Scope, receive: Receive, send: Send) -> None:
+        ...
+
+    return asgi_app
 
 
 @pytest.fixture()
@@ -163,13 +168,13 @@ def session_backend_config_memory(memory_store: MemoryStore) -> ServerSideSessio
 
 
 @pytest.fixture
-def session_middleware(session_backend: BaseSessionBackend) -> SessionMiddleware[Any]:
+def session_middleware(session_backend: BaseSessionBackend, mock_asgi_app: ASGIApp) -> SessionMiddleware[Any]:
     return SessionMiddleware(app=mock_asgi_app, backend=session_backend)
 
 
 @pytest.fixture
 def cookie_session_middleware(
-    cookie_session_backend: ClientSideSessionBackend,
+    cookie_session_backend: ClientSideSessionBackend, mock_asgi_app: ASGIApp
 ) -> SessionMiddleware[ClientSideSessionBackend]:
     return SessionMiddleware(app=mock_asgi_app, backend=cookie_session_backend)
 
diff --git a/tests/middleware/test_compression_middleware.py b/tests/middleware/test_compression_middleware.py
--- a/tests/middleware/test_compression_middleware.py
+++ b/tests/middleware/test_compression_middleware.py
@@ -1,4 +1,5 @@
-from typing import AsyncIterator, Literal
+from typing import AsyncIterator, Callable, Literal
+from unittest.mock import MagicMock
 
 import pytest
 
@@ -7,9 +8,11 @@
 from litestar.enums import CompressionEncoding
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.handlers import HTTPRouteHandler
+from litestar.middleware.compression import CompressionMiddleware
 from litestar.response_containers import Stream
 from litestar.status_codes import HTTP_200_OK
 from litestar.testing import create_test_client
+from litestar.types.asgi_types import ASGIApp, HTTPResponseBodyEvent, HTTPResponseStartEvent, Message, Scope
 
 BrotliMode = Literal["text", "generic", "font"]
 
@@ -164,3 +167,29 @@ def test_config_brotli_lgwin_validation(brotli_lgwin: int, should_raise: bool) -
             CompressionConfig(backend="brotli", brotli_gzip_fallback=False, brotli_lgwin=brotli_lgwin)
     else:
         CompressionConfig(backend="brotli", brotli_gzip_fallback=False, brotli_lgwin=brotli_lgwin)
+
+
+@pytest.mark.parametrize(
+    "backend, compression_encoding", (("brotli", CompressionEncoding.BROTLI), ("gzip", CompressionEncoding.GZIP))
+)
+async def test_compression_streaming_response_emitted_messages(
+    backend: Literal["gzip", "brotli"],
+    compression_encoding: Literal[CompressionEncoding.BROTLI, CompressionEncoding.GZIP],
+    create_scope: Callable[..., Scope],
+    mock_asgi_app: ASGIApp,
+) -> None:
+    mock = MagicMock()
+
+    async def fake_send(message: Message) -> None:
+        mock(message)
+
+    wrapped_send = CompressionMiddleware(
+        mock_asgi_app, CompressionConfig(backend=backend)
+    ).create_compression_send_wrapper(fake_send, compression_encoding, create_scope())
+
+    await wrapped_send(HTTPResponseStartEvent(type="http.response.start", status=200, headers={}))
+    # first body message always has compression headers (at least for gzip)
+    await wrapped_send(HTTPResponseBodyEvent(type="http.response.body", body=b"abc", more_body=True))
+    # second body message with more_body=True will be empty if zlib buffers output and is not flushed
+    await wrapped_send(HTTPResponseBodyEvent(type="http.response.body", body=b"abc", more_body=True))
+    assert mock.mock_calls[-1].args[0]["body"]