Skip to content

Negotiate gzip on the response content type - #51

Merged
umputun merged 4 commits into
masterfrom
fix/gzip-response-negotiation
Aug 18, 2026
Merged

Negotiate gzip on the response content type#51
umputun merged 4 commits into
masterfrom
fix/gzip-response-negotiation

Conversation

@paskal

@paskal paskal commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #49, which this branch is based on, so the diff here is the gzip change alone. The base retargets to master once #49 merges.

Gzip picked compression from the request Content-Type. A normal GET does not send one, so the lookup fell back to application/octet-stream and compression was skipped for exactly the JSON and HTML responses the middleware exists to compress. In the other direction, a request that happened to carry a matching type could get an unrelated binary response compressed.

The middleware was therefore close to a no-op in ordinary use. Wrapping a handler that renders JSON and requesting it with Accept-Encoding: gzip returns an uncompressed body on master. The existing tests passed only because they set a request Content-Type before each call, which encoded the faulty contract; two of their assertions changed here and are the reason.

The decision now waits until the response content type is known, taken from the header the handler set or, when it set none, sniffed from the first chunk of the body with http.DetectContentType. The public signature is unchanged, and the default content-type list is the same.

Some smaller corrections came with it:

  • Accept-Encoding is parsed into tokens rather than substring-matched, so gzip;q=0 is honoured as a refusal instead of being read as a request for gzip.
  • Responses that carry no body, 204 and 304, are no longer given a Content-Encoding.
  • Content-Length is dropped only when the body is actually compressed. Previously every response passing through the writer lost it, including uncompressed ones.
  • Flush, Hijack and Unwrap are implemented, so streaming responses, protocol upgrades and http.ResponseController keep working through the wrapper. A hijacked connection also suppresses the deferred header write, which would otherwise log WriteHeader on hijacked connection.

What changes for callers. Nothing at the API level, but responses that were silently going out uncompressed will now be compressed, which is what wrapping a handler in Gzip asks for. Anything asserting on exact response sizes will see different numbers.

Note for whoever runs the suite: TestBenchmarks_Handler is timing-sensitive and flakes under load on an unrelated assertion, assert.InDelta(t, 50000, res.MaxRespTime, 10000), which requires a 50ms sleep to finish within 60ms. It reproduces on master and with the gzip code not exercised at all, roughly one run in ten on a busy machine. Not addressed here, but worth a look separately.

@paskal
paskal requested a review from umputun as a code owner August 18, 2026 21:50
@coveralls

coveralls commented Aug 18, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 32197728184

Coverage increased (+0.04%) to 97.493%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (135 of 137 lines covered, 98.54%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
gzip.go 137 135 98.54%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1675
Covered Lines: 1633
Line Coverage: 97.49%
Coverage Strength: 47.65 hits per line

💛 - Coveralls

@paskal
paskal force-pushed the fix/gzip-response-negotiation branch from 4931c7c to c220bee Compare August 18, 2026 21:53
@paskal
paskal force-pushed the fix/gzip-response-negotiation branch from c220bee to eb07e9f Compare August 18, 2026 23:08
@umputun

umputun commented Aug 18, 2026

Copy link
Copy Markdown
Member

this one is fine on its own, the response-content-type negotiation is the right fix and eb07e9f covers the edge cases well, 1xx passthrough, already-encoded bodies, 206 and Content-Range, and the panic path.

problem is it's based on fix/middleware-findings, and #49 is held on the Metrics commit. Can you retarget this to master so it isn't blocked by an unrelated argument? Then it can merge right away.

@paskal
paskal force-pushed the fix/gzip-response-negotiation branch 2 times, most recently from 021ccba to de7a045 Compare August 18, 2026 23:23
Base automatically changed from fix/middleware-findings to master August 18, 2026 23:24
@paskal
paskal force-pushed the fix/gzip-response-negotiation branch from de7a045 to 100c791 Compare August 18, 2026 23:24
@paskal

paskal commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased and the edge cases from your note are covered, plus three more found on re-review: 101 was being treated as an interim 1xx so the deferred close could append gzip bytes after a protocol upgrade, 205 was not excluded, and sniffing ran before the Content-Encoding check so an already-encoded body got a type guessed from its compressed bytes.

On retargeting: GitHub refuses it with Cannot change the base branch because the pull request is part of a stack, for both the CLI and the API. #52 detached fine, this one is still pinned to #49. I left the branch based on fix/middleware-findings so the diff stays correct rather than showing this PR reverting #49. The Metrics commit you objected to is inverted in 8d8dfc9, so #49 should no longer be the blocker, and merging it will retarget this automatically. If you'd rather not wait, retargeting from your side should work.

@umputun

umputun commented Aug 18, 2026

Copy link
Copy Markdown
Member

#49 is merged, and #52 too, so nothing is blocking this one any more. GitHub retargeted it to master on its own.

it still shows conflicting though. Your 23:24 rebase was onto fix/middleware-findings, so the branch carries #49's ten original commits, and those clash with the squashed version that's now on master. A rebase onto master should drop them and leave just the four gzip commits.

the three extra cases you caught on re-review are the right ones, particularly 101 being treated as interim so the deferred close could append gzip bytes after an upgrade.

paskal added 4 commits August 19, 2026 00:33
Previously the middleware picked compression from the request Content-Type,
which a normal GET does not send, so it fell back to application/octet-stream
and skipped compression for exactly the JSON and HTML responses it exists to
compress. A request that did carry a matching type could equally get an
unrelated binary response compressed. The old tests set a request Content-Type,
which is why they passed.

The decision now waits until the response content type is known, taken from the
handler's header or sniffed from the first chunk when it set none. Along the
way: Accept-Encoding is parsed so gzip;q=0 is honoured, bodyless 204 and 304
responses are left alone, Content-Length is dropped only when the body is
actually compressed, and Flush, Hijack and Unwrap pass through so streaming and
protocol upgrades keep working.
Six defects in the new writer, each with a test that fails without the fix:

Flush before the first write committed the headers while the compression
decision was still open, so a later textual write went out gzipped under
identity headers. The decision is now settled before the headers leave.

A panicking handler was committed as 200 by the deferred close, which left an
outer Recoverer unable to replace it with a 500. An uncommitted response is now
left alone when the handler did not return normally.

A response the handler had already encoded was compressed again and relabelled
gzip, and a 206 was compressed although its Content-Range offsets describe the
uncompressed representation. Both are skipped now.

WriteHeader let a second call replace the status when the first could not commit
for want of a content type, where net/http keeps the first. Interim 1xx
responses pass straight through, so Early Hints no longer becomes the final
status.

Accept-Encoding gave a wildcard precedence over a named gzip entry, and read
only the first header field, so "gzip;q=0" split across fields or ordered after
a wildcard read as acceptance.
101 was caught by the interim 1xx passthrough, so the deferred close could append
gzip bytes after the connection had been handed to another protocol. It counts as
final now, and along with 205 it is excluded from compression as neither carries
content.

Sniffing also ran before the Content-Encoding check, labelling an already encoded
body with a type guessed from its compressed bytes, which net/http suppresses.
@paskal
paskal force-pushed the fix/gzip-response-negotiation branch from 100c791 to ebf24f8 Compare August 18, 2026 23:34
@umputun
umputun merged commit 7e0b665 into master Aug 18, 2026
6 checks passed
@paskal
paskal deleted the fix/gzip-response-negotiation branch August 18, 2026 23:36
@umputun

umputun commented Aug 18, 2026

Copy link
Copy Markdown
Member

found a gap in the 101 handling after this merged, worth a follow-up.

WriteHeader(101) only decides and commits when Content-Type is already set. On the plain upgrade sequence, WriteHeader(101) with no content type followed by Hijack, the wrapper records statusSet and status but never forwards them; Hijack then sets hijacked, and close returns at the hijacked guard. So the 101 status line never reaches the underlying writer, where net/http would have written it. TestGzip_SwitchingProtocols sets a content type, which is why it passes.

narrow in practice, gorilla/websocket hijacks and writes the raw 101 itself rather than calling WriteHeader, so the common upgrade path never hits it. Still a difference from net/http for a handler that does use that sequence.

fix looks like committing 101 immediately regardless of content type, with a regression test that omits Content-Type before hijacking.

paskal added a commit that referenced this pull request Aug 18, 2026
WriteHeader only committed when a Content-Type was already set, so the plain
upgrade sequence, 101 with no content type followed by Hijack, recorded the
status and never forwarded it: Hijack set hijacked and close returned at that
guard, leaving the client with the raw protocol bytes and no status line where
net/http would have written one.

101 now commits immediately, which it can do because it carries no body to sniff
and is already excluded from compression. The regression test omits Content-Type
and reads the raw socket, so it fails without the fix.

Reported by umputun on #51.
@paskal

paskal commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, confirmed and fixed in #55 (96800ae), which was already open against the same WriteHeader/Hijack path.

Reproduced exactly as you described: with no Content-Type, WriteHeader(101) recorded the status, Hijack set hijacked, and close returned at that guard, so the client got raw-protocol-bytes and no status line. 101 commits immediately now, which is safe since it carries no body to sniff and decide already excludes it from compression.

The regression test omits Content-Type and reads the raw socket instead of going through the HTTP client, so it asserts on what actually reaches the wire. It fails on the merged code.

umputun pushed a commit that referenced this pull request Aug 18, 2026
WriteHeader only committed when a Content-Type was already set, so the plain
upgrade sequence, 101 with no content type followed by Hijack, recorded the
status and never forwarded it: Hijack set hijacked and close returned at that
guard, leaving the client with the raw protocol bytes and no status line where
net/http would have written one.

101 now commits immediately, which it can do because it carries no body to sniff
and is already excluded from compression. The regression test omits Content-Type
and reads the raw socket, so it fails without the fix.

Reported by umputun on #51.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants