Skip to content

v1.28.0: Brotli (br) response compression - #192

Open
gmpassos wants to merge 4 commits into
masterfrom
feat/brotli
Open

gmpassos wants to merge 4 commits into
masterfrom
feat/brotli

Conversation

@gmpassos

Copy link
Copy Markdown
Contributor

Summary

Adds Brotli (br) response compression next to GZip, using brotli_neo ^0.2.0 (pure Dart: no native library, no FFI). It also fixes how Accept-Encoding is handled.

  • Negotiation. Accept-Encoding is now parsed properly: q-values, q=0 and *. Each response uses the best encoding from APIServerConfig.compressionEncodings (default [br, gzip]). On a tie, the server's order wins.
  • Where it applies:
    • JSON payloads, through AutoCompressSink, which generalizes AutoGZipSink. AutoGZipSink is kept and works as before.
    • Other API responses (compressResponse).
    • Static files (createCompressionMiddleware).
    • The static files cache, which keeps identity, gzip and br variants per file. A gzip file is converted to br when the client prefers it.
  • Fallback: if the Brotli encoder throws, the next accepted encoding is used.
  • Vary: Accept-Encoding is sent with responses that can be compressed.
  • Request bodies: Content-Encoding: br payloads are decompressed (decompressPayload), and maxPayloadLength is enforced while decoding.
  • Config, each available as an argument, a server.* config entry and a JSON field:
    • compressionEncodings: an empty list disables compression.
    • brotliQuality: 0..11, default 5. Cached static files use at least 9.

Fix: JSON was gzipped without Accept-Encoding

_jsonEncodePayload gzipped every JSON payload of 512 bytes or more, whatever the client sent in Accept-Encoding. It now compresses only with an encoding the client accepts. APIServer.resolveBody/resolveBodySync called without the new compression parameter keep the old behavior (always GZip), for direct callers.

Breaking: Dart SDK >=3.13.5

brotli_neo requires it. Raising the SDK constraint changes the language version, so this PR also has to:

  • reformat the sources (the formatter style follows the language version);
  • apply prefer_initializing_formals (private named parameters). Public parameter names are unchanged, so no call site changes;
  • regenerate the reflection files (languageVersion goes from 3.10.0 to 3.13.0).

Those are in separate commits, so the feature commit (v1.28.0: Brotli ...) can be reviewed on its own.

Benchmark (JIT, entity-list JSON, brotli_neo vs dart:io gzip level 4)

JSON gzip L4 br q5 br q9
19 KB 1990 B / 0.05 ms 1485 B / 0.17 ms 1369 B / 3.1 ms
198 KB 18.9 KB / 0.35 ms 14.2 KB / 1.0 ms 12.0 KB / 12.5 ms
2 MB 182 KB / 3.5 ms 132 KB / 9.1 ms 91 KB / 170 ms

All the outputs were also decoded with the reference brotli CLI.

Tests

  • New test/bones_api_utils_compression_test.dart covers Accept-Encoding parsing and negotiation, Brotli round trips at every quality, maxOutputSize, corrupted input, compressResponse, the middleware, and interop with the brotli CLI (skipped when the CLI isn't installed).
  • AutoCompressSink/BrotliSink (sink tests), br cache variants, config args/JSON/apiConfig, br request payloads.
  • HTTP end to end: br JSON/text/static, no compression without Accept-Encoding (regression), q=0, a gzip-only server.
  • Local runs: dart format, dart analyze --fatal-infos, dependency_validator and the VM unit suite (-x docker -x concurrency) all pass, and so does ensure_build. The Docker DB suites will run in CI.

🤖 Generated with Claude Code

gmpassos and others added 3 commits October 26, 2026 06:15
Mechanical changes required by raising the SDK constraint to
`>=3.13.5` (next commit, for `brotli_neo`):

- `dart format`: the formatter style follows the language version.
- `dart fix --code=prefer_initializing_formals`: private named
  parameters (`this._credential`). The public parameter names are
  unchanged, so no call site changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `brotli_neo` (pure Dart) for Brotli, next to GZip, negotiated with the
  request's `Accept-Encoding` (q-values, `q=0`, `*`):
  - JSON payloads (`AutoCompressSink`), other API responses
    (`compressResponse`) and static files (`createCompressionMiddleware`).
  - Static files cache: identity, `gzip` and `br` variants (transcoding
    `gzip` to `br` when preferred).
  - Falls back to the next accepted encoding if the Brotli encoder fails.
- Fix: JSON payloads of 512+ bytes were GZip-compressed even without
  `Accept-Encoding: gzip`.
- `Accept-Encoding` parsed (was a substring match).
- `Vary: Accept-Encoding` on compressible responses.
- `APIServerConfig.compressionEncodings` and `brotliQuality`
  (args, `server.*` config, JSON).
- `Content-Encoding: br` request payloads (`decompressPayload`), with
  `maxPayloadLength` enforced while decoding.
- Dart SDK `>=3.13.5`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.40719% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.54%. Comparing base (ed9e707) to head (2a85474).

Files with missing lines Patch % Lines
lib/src/bones_api_utils_compression.dart 94.89% 10 Missing ⚠️
lib/src/bones_api_entity_db_postgres.dart 73.33% 4 Missing ⚠️
lib/src/bones_api_utils_sink.dart 95.45% 3 Missing ⚠️
lib/src/bones_api_server_cache.dart 98.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #192      +/-   ##
==========================================
- Coverage   95.55%   95.54%   -0.02%     
==========================================
  Files          73       74       +1     
  Lines       25179    25530     +351     
==========================================
+ Hits        24061    24393     +332     
- Misses       1118     1137      +19     
Flag Coverage Δ
unittests 95.54% <96.40%> (-0.02%) ⬇️

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

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- `APIBrotli.decode`: retry at `maxOutputSize` (the decoder stopped at
  the last buffer doubling below the limit, rejecting valid payloads).
- ERROR path and `apiResponse2` branch: JSON not compressed in
  `resolveBody` (its headers were lost: a compressed body without
  `Content-Encoding`); compressed later by `compressResponse`.
- `canCompressResponse`: no `1xx`/`204`/`206`/`304` or `Content-Range`.
- Static files: high quality only for cacheable responses
  (`APIServerResponseCache.isCacheable`, `resolveCompression`).
- Cache: failed conversions not retried; compressed `404` keeps its
  `Content-Encoding` and checks `Accept-Encoding`; `Accept-Encoding`
  parsed once per request.
- `x-gzip` taken as `gzip`.
- `resolveCompressionEncodings`: unknown encodings ignored (logged),
  defaults if none is known (was: compression silently disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gmpassos

Copy link
Copy Markdown
Contributor Author

Review fixes (2a85474)

A code review found 10 issues. All of them are fixed, each with a regression test:

  1. Brotli request decode limit. BrotliDecoderV2.decode stops at the last power-of-two buffer below the limit, so a 9 MB body under a 10 MB maxPayloadLength was rejected. It now retries once with a buffer at the limit.
  2. ERROR path and apiResponse2 branch. A JSON body was compressed in resolveBody, but its Content-Encoding header was written after the response headers had been resolved, so it was lost. The client got a compressed body with no header (a GZip body that could be Brotli-compressed again). Both paths now leave compression to compressResponse.
  3. Cache: failed conversion. A failed gzip→br conversion skipped the cache on every request. Failed target encodings are now remembered per cached entry.
  4. Static files at q9+ when not cacheable. High quality is now used only when APIServerResponseCache.isCacheable is true, through a new resolveCompression option on the middleware.
  5. Cached compressed 404. It was served without Content-Encoding, to every client. It now keeps the header (plus Vary) and is only served to clients that accept that encoding.
  6. 206 / Content-Range. 1xx, 204, 206, 304 and any response with Content-Range are no longer compressed.
  7. Unknown compression_encodings. These silently turned all compression off. Unknown names are now ignored with a log message, and if none is known the defaults are used. An empty value or none still disables compression.
  8. x-gzip is accepted as gzip (RFC 9110).
  9. Altitude (partially). Items 2 and 9 were the same ordering problem, and it's fixed in both branches. I kept the streaming GZip JSON sink, which avoids buffering the uncompressed JSON for gzip.
  10. Accept-Encoding parsing now happens once per request in the cache.

Local runs: dart format, analyze --fatal-infos, the VM unit suite, ensure_build, publish --dry-run, dependency_validator and Chrome (707 tests) all pass.

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.

1 participant