From 19b41af2e4797606bc23f8612a9ba65fe54e5007 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 14:08:27 +0000 Subject: [PATCH] =?UTF-8?q?perf(http):=20single=20smart=20compression=20la?= =?UTF-8?q?yer=20=E2=80=94=20stop=20compressing=20media=20on=20/api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api router added its own predicate-less CompressionLayer (routes.rs), nested inside the global predicate-aware one in main.rs. As the inner layer it compressed responses first, so the global predicate that skips already- compressed media was bypassed for every /api response: video/audio/image/zip downloads got Brotli-compressed (CPU + first-byte latency for ~0 bytes saved) and lost their Content-Length (forced to chunked -> no client progress bar). - Remove the redundant /api CompressionLayer; /api now flows through the single global layer in main.rs. - Make that predicate smarter: compress by default so nothing shrinkable is missed, and skip ONLY already-compressed types. It no longer blanket-excludes image/*, so image/svg+xml (text, ~70% shrink) now compresses; raster formats are listed individually. Added the previously-missed already-compressed types: Office (docx/xlsx/pptx), ODF, epub, jar, apk, 7z/rar/bzip2/zstd/xz, woff/woff2 fonts, icons. Net: media downloads keep Content-Length and skip pointless compression, while text/JSON/JS/CSS/SVG/XML/ttf/otf/wasm still compress. fmt + clippy clean. https://claude.ai/code/session_01UtfkS3nZF1vrF5jNAps6wV --- src/interfaces/api/routes.rs | 14 ++++----- src/main.rs | 61 +++++++++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 4baff7c7..f2ac1662 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -9,7 +9,7 @@ use axum::{ }; use serde_json::json; use std::sync::Arc; -use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +use tower_http::trace::TraceLayer; use utoipa::OpenApi; /// Liveness probe — returns 200 if the process is running, no DB check. @@ -579,10 +579,10 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .with_state(app_state.clone()); router = router.nest("/users", users_router); - // Transparent compression (gzip + brotli) for all API responses. - // tower-http negotiates via Accept-Encoding and skips already-compressed - // content types automatically. No manual compression in handlers. - router - .layer(CompressionLayer::new().br(true).gzip(true)) - .layer(TraceLayer::new_for_http()) + // Compression is applied once, globally, in `main.rs` with a content-type + // aware predicate that skips already-compressed media. Re-applying it here + // would double-wrap `/api`: this inner layer (no predicate) would compress + // media downloads, burning CPU for ~0 gain and stripping `Content-Length`. + // So this router only adds tracing; compression is the global layer's job. + router.layer(TraceLayer::new_for_http()) } diff --git a/src/main.rs b/src/main.rs index 192b4146..eb558f7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -541,25 +541,70 @@ async fn main() -> Result<(), Box> { app = app.layer(DefaultBodyLimit::max(BODY_LIMIT)); // ── HTTP compression (gzip + Brotli) ───────────────────────────────── - // Negotiates the best encoding via Accept-Encoding. Skips responses - // that are already compressed or wouldn't benefit (images, video, etc.). - // Compatible with a future reverse proxy — if the proxy sees - // `Content-Encoding` it will pass the response through untouched. + // Negotiates the best encoding via Accept-Encoding. Policy: compress + // everything by default so no shrinkable response is ever missed (text, + // JSON, JS/CSS, XML, SVG, fonts ttf/otf, WASM…), and skip ONLY content + // that is already compressed — where a second pass burns CPU and adds + // latency for ~0 bytes saved. + // + // We deliberately do NOT blanket-exclude `image/*`: `image/svg+xml` is + // plain text and compresses ~70%, so the genuinely-compressed raster + // formats are listed individually instead, leaving SVG compressible. + // + // This is the single, global compression layer (the `/api` router used to + // add its own predicate-less one, which silently compressed media). It is + // reverse-proxy friendly: a proxy that sees `Content-Encoding` passes + // the response through untouched. { use tower_http::compression::CompressionLayer; use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove}; let predicate = SizeAbove::new(256) .and(NotForContentType::GRPC) - .and(NotForContentType::IMAGES) .and(NotForContentType::SSE) - .and(NotForContentType::const_new("application/octet-stream")) + // ── already-compressed raster images (SVG intentionally absent) ── + .and(NotForContentType::const_new("image/jpeg")) + .and(NotForContentType::const_new("image/png")) + .and(NotForContentType::const_new("image/gif")) + .and(NotForContentType::const_new("image/webp")) + .and(NotForContentType::const_new("image/avif")) + .and(NotForContentType::const_new("image/heic")) + .and(NotForContentType::const_new("image/heif")) + .and(NotForContentType::const_new("image/jp2")) + .and(NotForContentType::const_new("image/x-icon")) + .and(NotForContentType::const_new("image/vnd.microsoft.icon")) + // ── audio / video families (already compressed) ── + .and(NotForContentType::const_new("video/")) + .and(NotForContentType::const_new("audio/")) + // ── already-compressed web fonts; ttf/otf left compressible ── + .and(NotForContentType::const_new("font/woff")) + .and(NotForContentType::const_new("application/font-woff")) + // ── archives & compressed containers ── .and(NotForContentType::const_new("application/zip")) .and(NotForContentType::const_new("application/gzip")) + .and(NotForContentType::const_new("application/x-gzip")) .and(NotForContentType::const_new("application/x-tar")) + .and(NotForContentType::const_new("application/x-7z-compressed")) + .and(NotForContentType::const_new("application/x-rar-compressed")) + .and(NotForContentType::const_new("application/x-bzip2")) + .and(NotForContentType::const_new("application/zstd")) + .and(NotForContentType::const_new("application/x-xz")) + // ── zip-based document / app bundles (docx/xlsx/pptx, odf, epub…) ── + .and(NotForContentType::const_new( + "application/vnd.openxmlformats-officedocument", + )) + .and(NotForContentType::const_new( + "application/vnd.oasis.opendocument", + )) + .and(NotForContentType::const_new("application/epub+zip")) + .and(NotForContentType::const_new("application/java-archive")) + .and(NotForContentType::const_new( + "application/vnd.android.package-archive", + )) + // ── PDF: streams are usually already deflated; often large ── .and(NotForContentType::const_new("application/pdf")) - .and(NotForContentType::const_new("video/")) - .and(NotForContentType::const_new("audio/")); + // ── opaque binary we couldn't identify ── + .and(NotForContentType::const_new("application/octet-stream")); app = app.layer(CompressionLayer::new().compress_when(predicate)); }