Skip to content

rpc: add gzip response compression - #3595

Open
amir-deris wants to merge 19 commits into
mainfrom
amir/plt-640-rpc-compression-phase-1
Open

rpc: add gzip response compression#3595
amir-deris wants to merge 19 commits into
mainfrom
amir/plt-640-rpc-compression-phase-1

Conversation

@amir-deris

@amir-deris amir-deris commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds NewGzipHandler middleware (sei-tendermint/rpc/jsonrpc/server/gzip.go) that wraps any http.Handler with transparent gzip response compression using a sync.Pool-backed writer for allocation efficiency
  • Wires the handler into the Tendermint RPC server (sei-tendermint/internal/rpc/core/env.go) and the Cosmos SDK API server (sei-cosmos/server/api/server.go)
  • Compression is skipped for clients that do not advertise Accept-Encoding: gzip and for WebSocket upgrade requests (preserving http.Hijacker compatibility)

Test plan

  • Unit tests in gzip_test.go cover compressed and uncompressed paths, pool reuse, and WebSocket passthrough
  • Build passes: make build
  • Smoke-test a node locally: confirm RPC responses are gzip-encoded when Accept-Encoding: gzip is sent (e.g. curl -H "Accept-Encoding: gzip" http://localhost:26657/status | file -)
  • Confirm WebSocket connections (/websocket) are unaffected

link to design doc: Link

🤖 Generated with Claude Code

@amir-deris amir-deris self-assigned this Jun 16, 2026
@cursor

cursor Bot commented Jun 16, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
All RPC and REST API responses now pass through new compression middleware on the hot path; behavior is heavily tested but mis-handling WebSocket, caching (Vary), or partial-content responses could break clients.

Overview
Adds transparent gzip compression for HTTP RPC/API responses when clients send Accept-Encoding: gzip, via new NewGzipHandler middleware in the Tendermint JSON-RPC server package.

The middleware uses a pooled gzip writer, sets Vary: Accept-Encoding for cache safety, and skips compression when the client does not accept gzip, on WebSocket upgrades, for small known bodies (<1KB), and when responses already have encoding, Transfer-Encoding: identity, or Content-Range / bodyless statuses (204, 206, etc.). It also forwards http.Hijacker and avoids emitting a gzip footer on handler panic.

Wiring: the handler wraps the root HTTP stack on the main Tendermint RPC server (after CORS), the inspect RPC handler, and the Cosmos SDK API server (with or without unsafe CORS).

Tests in gzip_test.go cover round-trips, concurrency, streaming flush, WebSocket/hijack passthrough, and edge-case headers.

Reviewed by Cursor Bugbot for commit cb74faa. Bugbot is set up for automated code reviews on this repo. Configure here.

@amir-deris amir-deris changed the title Added gzip handler for rpc rpc: add gzip response compression (phase 1) Jun 16, 2026
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 23, 2026, 8:53 AM

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.44068% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.06%. Comparing base (96fa55d) to head (cb74faa).

Files with missing lines Patch % Lines
sei-tendermint/rpc/jsonrpc/server/gzip.go 87.71% 11 Missing and 3 partials ⚠️
sei-cosmos/server/api/server.go 0.00% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3595      +/-   ##
==========================================
- Coverage   60.20%   58.06%   -2.15%     
==========================================
  Files        2328     2135     -193     
  Lines      194502   172356   -22146     
==========================================
- Hits       117104   100071   -17033     
+ Misses      66849    63414    -3435     
+ Partials    10549     8871    -1678     
Flag Coverage Δ
sei-chain-pr 43.97% <86.44%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-tendermint/internal/inspect/rpc/rpc.go 62.12% <100.00%> (ø)
sei-tendermint/internal/rpc/core/env.go 78.48% <100.00%> (+0.12%) ⬆️
sei-cosmos/server/api/server.go 64.51% <0.00%> (-1.62%) ⬇️
sei-tendermint/rpc/jsonrpc/server/gzip.go 87.71% <87.71%> (ø)

... and 514 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@amir-deris
amir-deris requested review from bdchatham and masih June 16, 2026 01:01
@amir-deris amir-deris changed the title rpc: add gzip response compression (phase 1) rpc: add gzip response compression Jun 16, 2026

@bdchatham bdchatham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few findings from an independent cross-review (systems / networking / security lenses) of the gzip middleware. The negotiation design is solid and client-side is backward-compatible — these three are origin-side concerns worth addressing before enabling on the public RPC/REST listeners. Line comments below.

Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated

@bdchatham bdchatham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: a few stream-lifecycle (layer-2) correctness nits. A corrupt/truncated stream is worse than a missed compression, so these are worth tightening.

Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
@bdchatham

Copy link
Copy Markdown
Contributor

On testing — I'd lean on a single-process round-trip harness here rather than adding more httptest.NewRecorder cases. The recorder is actually why the current suite stays green while the lifecycle issues above survive: it buffers Write calls into memory, so it never exercises real chunked transfer-encoding, real Flush timing, Content-Length handling, or a real http.Hijacker. Swap it for httptest.NewServer and you get all of that over a loopback connection — still one process, still fast — with a real client decoding the far end.

The core invariant is just bytes in == bytes out, for every shape of payload. One helper serves a body through NewGzipHandler, the client reads and gzip-decodes the response, and we assert it matches the original. Then sweep sizes that each mean something: 0 and 1 byte, sizes straddling net/http's ~4KB write buffer (4095 / 4096 / 4097) to force real chunking, and a ~1MB body — run once with Content-Length set and once without, since those are genuinely different paths through the writer.

The one subtlety worth calling out: decode with gzip.Reader.Multistream(false) and then assert the next read returns io.EOF. That's what actually catches the early-close corruption — if raw bytes get appended after the gzip footer, a plain io.ReadAll(gzip.NewReader(...)) silently ignores them and the test passes anyway. That trailing-byte check is the difference between a feel-good round-trip and one that fails on the real bug.

Two more the recorder can't express: a concurrent variant (a couple hundred goroutines, each asserting its own payload comes back) run under -race to validate the pool reuse, and a streaming case where the handler writes a chunk, flushes, then blocks — the client should see chunk one before chunk two is written. If gzip buffers instead of flushing, that test just deadlocks, which is a clear enough signal.

The swallowed Close() error and the Hijacker case are the exceptions — those want a small custom ResponseWriter (fault injection / interface assertion) rather than a round-trip — but everything else falls out of that one harness.

Happy to write the harness up if it's useful. And dm me if you have questions on any of this.

Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go

@amir-deris amir-deris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed feedbacks!

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a well-structured, well-tested gzip response-compression middleware (NewGzipHandler) and wires it into the Tendermint RPC, inspect RPC, and Cosmos API servers. No blocking issues, but a couple of response-corruption edge cases and a largely-ineffective size threshold are worth addressing.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Performance: the minCompressBytes (1024) skip only triggers when a Content-Length header is present at first write. Most Tendermint JSON-RPC handlers write the body directly without setting Content-Length, so the threshold rarely applies and small responses get gzipped anyway — wasting CPU and potentially enlarging sub-1KB bodies. Consider buffering the first chunk to decide, or accept the cost explicitly.
  • Cursor's review pass (cursor-review.md) produced no output — only the OpenAI Codex pass returned findings, which are incorporated below.
  • Test plan checkboxes in the PR description (build, local smoke-test, websocket check) are unchecked; confirm these were exercised before merge given this sits in the hot path for all RPC/API responses.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go
Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-structured, thoroughly tested gzip response-compression middleware wired into the Tendermint RPC, inspect RPC, and Cosmos SDK API servers. The implementation is solid, but it compresses 206 Partial Content / Range responses, which corrupts range-aware clients of the swagger static file server; a couple of smaller robustness/operability notes also apply.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • No runtime toggle: compression is now unconditionally enabled on all three servers (Tendermint RPC, inspect RPC, Cosmos SDK API). For a cost-optimization rollout, consider a config flag so it can be disabled without a redeploy if it causes unexpected CPU load or client issues.
  • Already-compressed payloads (e.g. PNG/woff/.gz static swagger assets, or pre-gzipped upstream responses without a Content-Encoding header) will be re-compressed, wasting CPU for little/no gain. The Content-Encoding passthrough check handles explicitly-encoded responses, but there is no content-type skip-list. Minor; acceptable if JSON-RPC is the dominant traffic.
  • REVIEW_GUIDELINES.md and cursor-review.md were empty, so no repository-specific standards and no Cursor second-opinion pass were available to incorporate. The Codex pass produced exactly one finding (the 206 issue), which is incorporated below.
  • Test plan checkboxes in the PR description (build, local smoke-test, WebSocket verification) are unchecked; confirm these manual steps were run before merge.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/rpc/jsonrpc/server/gzip.go

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has reached its monthly code review spending cap.

An organization admin can view or raise the cap at claude.ai/admin-settings/claude-code. The cap resets at the start of the next billing period.

Once the cap resets or is raised, push a new commit or reopen this pull request to trigger a review.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a well-structured, well-tested gzip response-compression middleware and wires it into the Tendermint RPC, inspect RPC, and Cosmos API servers. No blocking correctness bugs found; a few edge-case and efficiency concerns are worth addressing before merge.

Findings: 0 blocking | 6 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) and the repo's REVIEW_GUIDELINES.md were both empty, so those inputs contributed nothing to this synthesis; review relied on the diff and Codex's pass.
  • Efficiency: because most handlers do not set Content-Length before writing (net/http computes it only after the handler returns), the minCompressBytes small-response skip in init() rarely triggers. Tiny JSON-RPC responses (e.g. {"jsonrpc":"2.0","id":1}) will therefore be gzipped and grow by ~20+ bytes of framing overhead, which slightly works against the egress-reduction goal in the design doc. Consider buffering small writes to apply the threshold even without an explicit Content-Length, or documenting the limitation.
  • No configuration flag to enable/disable compression: the middleware is unconditionally wired into all three servers. Consider gating it behind config so operators can opt out without a code change.
  • Consider a brief note in the changelog / operator docs that RPC responses are now gzip-encoded when clients advertise Accept-Encoding: gzip, since this changes on-the-wire behavior for existing clients/proxies.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Vary must be set on every response — compressed or not — so that CDN
// caches key on Accept-Encoding and never serve a wrong variant.
ensureVaryAcceptEncoding(w.Header())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Vary: Accept-Encoding is set here before the inner handler runs. If a downstream handler or CORS middleware later calls w.Header().Set("Vary", ...) (rather than Add), it overwrites this value and drops Accept-Encoding. A CDN keyed only on the remaining Vary token could then serve a gzip-encoded variant to a client that never sent Accept-Encoding: gzip. Of the two wirings, rs/cors (env.go) uses Header().Add("Vary", ...) and is safe, but gorilla handlers.CORS (sei-cosmos server.go) and any future handler using Set are not guaranteed to be. Safer to (re)ensure the Vary token at header-commit time (in the wrapper's init()/WriteHeader) as well as up front. (Raised by Codex.)

w.gz = gzPool.Get().(*gzip.Writer)
w.gz.Reset(w.resp)
hdr.Del("content-length")
hdr.Set("content-encoding", "gzip")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Setting Content-Encoding: gzip before the first write disables net/http's automatic Content-Type sniffing: with no explicit Content-Type, the server sniffs the compressed bytes and emits Content-Type: application/x-gzip instead of the real (uncompressed) type. Impact is low here because the Tendermint/Cosmos JSON-RPC handlers set Content-Type explicitly, but any handler relying on sniffing would be affected. Consider capturing/deriving Content-Type from the uncompressed bytes before enabling gzip. (Raised by Codex.)

Address review feedback on the gzip middleware:

- Skip compression for 206 Partial Content responses and any response
  carrying a Content-Range header, whose offsets describe the
  uncompressed body (reachable via the swagger http.FileServer served
  through the Cosmos API router).
- On a panic unwinding mid-response, return the pooled writer via abort()
  without emitting the gzip footer, so the recovery handler's plaintext
  500 body isn't framed behind a valid gzip trailer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
}
gzPool.Put(w.gz)
w.gz = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panic corrupts gzip-encoded responses

Medium Severity

abort returns the gzip writer to the pool without clearing Content-Encoding, then re-panics into recoverAndLogHandler, which writes a plaintext 500 directly to the inner ResponseWriter. After init has run, clients still see Content-Encoding: gzip with a non-gzip body, so decompression fails.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit affed4d. Configure here.

if f, ok := w.resp.(http.Flusher); ok {
f.Flush()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flush blocked by statusWriter wrapper

Low Severity

gzipResponseWriter.Flush type-asserts w.resp as http.Flusher, but in every Serve/ServeTLS path resp is statusWriter, which embeds the http.ResponseWriter interface and does not implement Flusher. Compressed bytes therefore sit in the server buffer until the handler returns, despite the streaming-flush test and comments claiming incremental delivery works.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit affed4d. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a well-tested gzip response-compression middleware wired into the Tendermint and Cosmos RPC/API servers; the implementation is careful (Accept-Encoding q-values, WebSocket/Hijack passthrough, bodyless-status and panic handling) but omits Content-Type preservation before compressing, which can produce a wrong sniffed Content-Type for handlers that rely on auto-detection.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • cursor-review.md and REVIEW_GUIDELINES.md are both empty — the Cursor pass produced no output, and no repo-specific review guidelines were available.
  • Security consideration (non-blocking): compressing responses that may combine sensitive data with attacker-influenced input over TLS enables BREACH-class compression oracles. RPC responses generally don't reflect secrets, but worth a note in the design doc given this sits behind CloudFront.
  • Nice test coverage overall (round-trip sizes, concurrency, streaming flush, panic footer, q-values, WebSocket passthrough). Two behaviors are untested and correspond to the findings below: Content-Type auto-detection on compressed responses, and Vary preservation when an inner handler overwrites Vary via Set.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

w.gz = gzPool.Get().(*gzip.Writer)
w.gz.Reset(w.resp)
hdr.Del("content-length")
hdr.Set("content-encoding", "gzip")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Content-Type is not preserved before compression. Once Content-Encoding: gzip is set here and Write sends gzip bytes to the underlying ResponseWriter, net/http's content sniffing (triggered when the inner handler didn't set Content-Type explicitly) inspects the gzip magic bytes and emits Content-Type: application/x-gzip. Standard gzip middleware captures/sets the content type from the first pre-compression write (or calls http.DetectContentType on the uncompressed bytes) to avoid this. The wired JSON-RPC and gRPC-gateway handlers set application/json explicitly so impact is limited, but any handler relying on auto-detection will now serve a wrong Content-Type. (Raised by Codex P2.)

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Vary must be set on every response — compressed or not — so that CDN
// caches key on Accept-Encoding and never serve a wrong variant.
ensureVaryAcceptEncoding(w.Header())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Vary: Accept-Encoding is set at handler entry, before the inner handler runs. An inner handler that does Header().Set("Vary", ...) would clobber it, letting a cache serve a compressed variant to a non-gzip client. The two CORS layers actually wired here (rs/cors and gorilla handlers.CORS) use Header().Add("Vary", ...), so the current wiring is safe — but this is fragile. Consider re-asserting Vary in init()/WriteHeader (when headers are committed) instead of at entry. (Raised by Codex P1; low practical impact given current wiring.)

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a well-tested, generic gzip response-compression middleware and wires it into the Tendermint RPC and Cosmos API servers. No blocking correctness or security issues found; two defensive suggestions from Codex are valid but low-impact for the handlers actually wired up here. Cursor produced no output.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) was empty — that pass produced no output.
  • Compression is now applied to unauthenticated RPC/API endpoints. For this codebase the responses are public blockchain data (no session secrets reflected with attacker-controlled input), so BREACH/CRIME risk is negligible and gzip.BestSpeed bounds CPU cost — noting only for completeness; no change needed.
  • Minor efficiency note: when the inner handler does not set Content-Length (e.g. chunked/streaming responses), init() always compresses even tiny bodies, so the minCompressBytes threshold only helps the known-length path. This matches the documented design intent; acceptable.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

w.gz = gzPool.Get().(*gzip.Writer)
w.gz.Reset(w.resp)
hdr.Del("content-length")
hdr.Set("content-encoding", "gzip")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Codex P2: content-type sniffing. If the inner handler writes a body without setting Content-Type, net/http sniffs the first bytes written to determine it — which will now be the gzip header, yielding Content-Type: application/x-gzip instead of the real type. Consider sniffing/preserving the uncompressed Content-Type (e.g. via http.DetectContentType on the first chunk) before the first compressed write. In practice the handlers wired up here (JSON-RPC writeHTTPResponse, gRPC-gateway, FileServer) all set Content-Type explicitly, so this is defensive rather than an active bug — hence a suggestion, but worth hardening since the middleware is generic.

if w.gz != nil {
_ = w.gz.Flush()
}
if f, ok := w.resp.(http.Flusher); ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Codex P1: gzipResponseWriter statically implements http.Flusher, so any wrapped handler sees w.(http.Flusher) == true and believes a flush reached the network — but Flush() only forwards when the underlying writer is a Flusher. In both wired-in paths the underlying writer is sei-tendermint's statusWriter (from recoverAndLogHandler), which embeds only http.ResponseWriter+http.Hijacker and is NOT a Flusher, so gz.Flush() runs but the network flush is a silent no-op. Note this is not a regression: statusWriter already masked net/http's Flusher before this PR, so streaming through this server never flushed incrementally, and the final response is still a complete/valid gzip stream. The only new wrinkle is falsely advertising flush capability. Fine to merge as-is; flagging so it's a conscious choice.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cb74faa. Configure here.

}
w.init()
w.resp.WriteHeader(status)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1xx responses disable later compression

Low Severity

WriteHeader treats 1xx statuses like final bodyless responses and sets inited permanently. Interim 1xx writes must not lock out compression, but a later final WriteHeader/Write then skips init, so an otherwise compressible response stays identity-encoded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cb74faa. Configure here.

Comment on lines +41 to +63
if len(length) > 0 {
if n, err := strconv.ParseUint(length, 10, 64); err == nil {
w.hasLength = true
w.contentLength = n
}
}

// Skip compression if the inner handler already encoded the response, opted
// out via Transfer-Encoding: identity, or emitted a byte range whose
// Content-Range offsets describe the uncompressed body.
if hdr.Get("content-encoding") != "" || hdr.Get("transfer-encoding") == "identity" ||
hdr.Get("content-range") != "" {
return
}

// Skip compression for small responses with a known Content-Length; below
// the threshold the gzip overhead outweighs the savings and the CPU cost
// per byte is not worth it for unauthenticated callers.
if w.hasLength && w.contentLength < minCompressBytes {
return
}

w.gz = gzPool.Get().(*gzip.Writer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The minCompressBytes size-skip safeguard in gzipResponseWriter.init() never triggers for the main Tendermint JSON-RPC handlers, because writeHTTPResponse/writeRPCResponse (sei-tendermint/rpc/jsonrpc/server/http_server.go) call WriteHeader then Write without ever setting Content-Length — so init() runs while hasLength is still false and every response, including tiny ones, gets gzip-compressed. This makes the added CPU-amplification guard effectively dead code for the primary RPC traffic it was meant to protect (it does still work for handlers that explicitly set Content-Length, e.g. the swagger http.FileServer path).

Extended reasoning...

What the bug is. gzipResponseWriter.init() (gzip.go:41-63) is supposed to skip compression for small responses via: if w.hasLength && w.contentLength < minCompressBytes { return }. hasLength/contentLength are only populated a few lines earlier from hdr.Get(\"content-length\"). If that header is empty at the time init() runs, hasLength stays false and the size-skip branch can never be taken — the writer falls through and always compresses.

The code path that triggers it. The two real call sites for the Tendermint JSON-RPC server are writeHTTPResponse (http_server.go:144-165) and writeRPCResponse (http_server.go:173-196). Both do exactly:

w.Header().Set(\"Content-Type\", \"application/json\")
w.WriteHeader(statusCode)
w.Write(body)

Neither sets Content-Length anywhere. gzipResponseWriter.WriteHeader invokes init() immediately (for any non-bodyless status), and at that point w.resp.Header() genuinely has no content-length key — net/http's own auto-computed Content-Length (when the whole body fits in one buffered write) is only applied by the standard library at a layer below this wrapper, and only after the handler returns, so this wrapper's init() never observes it. I verified this directly against the current http_server.go in the diff/tree: both functions set only Content-Type, call WriteHeader, then Write, with no Content-Length anywhere in between.

Why nothing else prevents it. The size guard is genuinely conditioned on hasLength, and there's no fallback that infers size from the body slice length that's about to be written in Write() — by the time Write() is called, init() (and the compress/no-compress decision) has already run and been locked in via WriteHeader. So for this specific call pattern (WriteHeader before Write, no explicit Content-Length), the threshold is structurally unreachable.

Concrete proof. Consider a request to /status with Accept-Encoding: gzip, returning a body like {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{...}} that happens to be 40 bytes:

  1. NewGzipHandler sees acceptsGzip(r) == true and no websocket upgrade, so it wraps w in a gzipResponseWriter.
  2. writeHTTPResponse runs: w.Header().Set(\"Content-Type\", ...), then w.WriteHeader(200).
  3. Inside WriteHeader, status 200 isn't in the bodyless set, so w.init() runs. hdr.Get(\"content-length\") is \"\" (never set) → hasLength = false. The size-skip branch if w.hasLength && w.contentLength < minCompressBytes evaluates to if false && ... → false → not taken. w.gz is allocated from the pool and Content-Encoding: gzip is set.
  4. w.Write(body) (40 bytes) goes through w.gz.Write(...), gzip-compressing a 40-byte payload (which nets out larger on the wire than the original once gzip's ~20-byte header/footer overhead is added), purely for CPU cost with no size benefit.

This holds for every JSON-RPC response through this path, regardless of size — the design intent (skip compressing small bodies) never activates for the traffic it was presumably added to protect; it only works for handlers elsewhere (e.g. the Cosmos API's swagger http.FileServer) that do set Content-Length before/without an explicit early WriteHeader.

Impact and severity. This is not a correctness bug — responses remain valid, complete gzip streams and clients decode them fine. The practical cost is wasted CPU per tiny request and ~20 bytes of gzip framing overhead on payloads that gain nothing from compression. Also, the writer pool uses gzip.NewWriterLevel(io.Discard, gzip.BestSpeed) (level 1), not the default level-6 that an earlier review comment assumed, so the actual CPU cost per byte is low — this meaningfully caps the amplification concern. Given that, this doesn't warrant blocking merge; it's a nit worth fixing so the safeguard actually does what its name implies.

How to fix. Determine hasLength/contentLength from the byte slice passed to the first Write() call when Content-Length hasn't been set explicitly (i.e., defer the compress/no-compress decision until the first Write, using len(b) as a stand-in for total size when the handler is known to write once), or have writeHTTPResponse/writeRPCResponse set Content-Length explicitly before calling WriteHeader (safe here since amir-deris already confirmed on the PR thread that every handler in this server marshals to a full byte slice and writes once, never streaming).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants