Skip to content

rpc: add gzip response compression#3595

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

rpc: add gzip response compression#3595
amir-deris wants to merge 18 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
Touches all public RPC/API HTTP responses and adds CPU work on large payloads; behavior is gated on Accept-Encoding and WebSockets are excluded, with extensive tests mitigating protocol edge cases.

Overview
Adds NewGzipHandler middleware that gzip-compresses HTTP responses when clients send Accept-Encoding: gzip, using a pooled gzip.Writer and skipping compression below a 1KB known Content-Length, for already-encoded bodies, bodyless/206/partial-content cases, and WebSocket upgrades (with http.Hijacker forwarding). Responses always get Vary: Accept-Encoding for safe CDN caching.

The handler is wired into the Tendermint RPC stack (internal/rpc/core/env.go), inspect RPC (internal/inspect/rpc/rpc.go), and the Cosmos API server (sei-cosmos/server/api/server.go), including CORS-wrapped paths. Broad gzip_test.go coverage exercises round-trips, concurrency, streaming flush, and edge cases.

Reviewed by Cursor Bugbot for commit affed4d. 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 15, 2026, 1:54 PM

@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.10%. Comparing base (ad863c7) to head (affed4d).

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   59.88%   58.10%   -1.78%     
==========================================
  Files        2288     2135     -153     
  Lines      190023   172748   -17275     
==========================================
- Hits       113786   100373   -13413     
+ Misses      66091    63488    -2603     
+ Partials    10146     8887    -1259     
Flag Coverage Δ
sei-chain-pr 44.22% <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 419 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>

@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 2 potential issues.

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 affed4d. Configure here.

}
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.)

@autoweave-bot

Copy link
Copy Markdown

This looks like a great way to speed things up! 🚀😌
Explore here →

Subweave map of sei-protocol/sei-chain#3595

Maintainer? Turn off weaves from non-maintainers →
Carefully crafted by Subweave · 🧶 used ~228k LLM tokens

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.

4 participants