Use `zlib.compressobj` instead of `GzipFile` in `GZipMiddleware`

Merged
KL
3mo ago
+13-34

Summary

Replace the gzip.GzipFile + io.BytesIO machinery in GZipResponder with zlib.compressobj using wbits=16 + zlib.MAX_WBITS, which produces the same gzip wire format (header, CRC32 and size trailer).

Rationale

The GzipFile approach dates back to the original middleware commit (#111, 2018) and requires supporting machinery that compressobj makes unnecessary:

  • the io.BytesIO buffer and per-chunk getvalue()/seek(0)/truncate() dance
  • the ExitStack lifecycle and __call__ override added in #3407 (a compressobj holds no resources that need closing)
  • GzipFile's internal BufferedWriter, which hides when compression work actually happens

Unlike GzipFile.write(), compressobj.compress() consumes and deflates its entire input during the call, making the compression cost explicit and self-contained. This also simplifies any future work offloading large-chunk compression to a worker thread (#3410), which currently needs a Z_NO_FLUSH workaround to drain GzipFile's buffer inside the worker. This is the same primitive aiohttp uses in its ZLibCompressor.

Net: 17 insertions, 34 deletions.

Behavior

  • Standard responses: byte-identical semantics; output is valid gzip with correct Content-Encoding/Content-Length.
  • Streaming responses: each chunk is flushed with Z_SYNC_FLUSH, matching the previous GzipFile.flush() behavior, so every streamed chunk remains immediately decodable by clients.
  • The gzip header no longer embeds a timestamp (mtime=0 equivalent), making compressed output deterministic for identical input.

Performance

No measurable change: 10 MiB level-9 response ~153 ms before and after; 160Ɨ64 KiB streamed chunks at level 6: 19.7 ms vs 19.1 ms (noise).

Validation

  • tests/middleware/test_gzip.py: 16 passed
  • scripts/check: mypy and ruff clean