Files
learningcircuit--local-deep…/docs/decisions/0005-reject-inapp-response-compression.md
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

4.3 KiB

ADR-0005: Reject in-app HTTP response compression

Date: 2026-06-28 Status: Accepted

Context

LDR serves its front-end as Vite-built static bundles (~2 MB JS + ~435 KB CSS) through the custom app_serve_static route in web/app_factory.py. In a single-process deployment with no reverse proxy these assets are sent uncompressed, so a proposal (PR #3158, then a re-scoped PR #4832) added flask-compress to compress them in-process with zstd/brotli/deflate, restricted to static MIME types.

The change was implemented, adversarially reviewed, and ultimately rejected. Both PRs are closed; nothing was merged.

Why the benefit is marginal

  • Localhost is the default and the point of the tool. Over loopback, transfer is effectively free; compression only spends CPU. Content-hashed bundles are already immutable-cached, so a browser fetches them once.
  • The win only materialises for users self-hosting LDR remotely, for multiple users, with no reverse proxy — a small slice of a "Local" research tool.
  • Those same users should run a reverse proxy anyway (for TLS), and nginx / Caddy then provide compression and a disk-cached compressed artifact for free — strictly better than re-compressing on every cold request in-process (flask-compress's COMPRESS_CACHE_BACKEND defaults to None, so there is no compressed-output cache).

Why the cost/risk is real

In-app compression bolts an HTTP-correctness surface onto the app. Adversarial review found, and empirically reproduced:

  • Malformed Range/206 responses. flask-compress has no range guard: a Range request with a compressible Accept-Encoding yields a 206 whose Content-Range describes the identity (uncompressed) coordinates while the body is compressed (e.g. Content-Range: bytes 0-1023/14000 with a 23-byte zstd body). This is malformed per RFC 9110 and corrupts conformant slicing intermediaries: nginx's slice module resets the connection ("unexpected range in slice response"), CloudFront caches corrupt byte math, and curl -C - / download-manager resumes break.
  • Varnish mis-serve. Varnish (default http_gzip_support=on) deliberately ignores Vary: Accept-Encoding; with the standard "brotli through Varnish" VCL it can cache a brotli body and serve it to a gzip-only client.
  • CDN re-opens BREACH. Cloudflare and Fastly compress text/html themselves by default, so behind them the CSRF-token HTML is compressed regardless of an in-app MIME allow-list. (LDR's CSRF token is already per-render masked by Flask-WTF, which is the durable BREACH mitigation — not declining to compress in-process.)
  • CPU amplification. app_serve_static is public and @limiter.exempt; with no compressed-output cache, a client can force repeated compression of the ~2 MB bundle. Pre-change, static serving was a near-zero-CPU file send.

The benefit accrues to a minority; several of the risks affect more setups (anyone behind a slicing CDN, Varnish, or a TLS-terminating CDN). That is a poor trade for a local-first tool.

Decision

Do not add in-app HTTP response compression to the Flask application. Do not re-introduce flask-compress or an equivalent runtime compressor.

For remote / multi-user deployments, the guidance is to front LDR with a reverse proxy (nginx / Caddy), which handles TLS, compression, and caching — see Deploying behind a reverse proxy.

If asset compression is ever genuinely wanted without a proxy, use build-time pre-compression instead: have Vite emit .br / .gz artifacts served as plain static files. That has zero per-request CPU cost, adds no runtime dependency, and — because the files are served plainly — avoids the content-negotiation, conditional-request, and byte-range edge cases above.

Consequences

  • The codebase keeps a near-zero-CPU static file path and no new dependency (flask-compress + transitive backports-zstd).
  • Remote operators get compression from their reverse proxy/CDN, which is where it belongs.
  • Two of PR #3158's original items were unrelated and already shipped: static Cache-Control headers (#3185 / #3207) and removal of the duplicate styles.css link (#3207). Only the compression idea is rejected here.