GET api/Profile/images/{id} returns 500 — response compression grows already-compressed images past Content-Length #851

Closed
opened 2026-07-25 22:42:03 +00:00 by spikerj · 1 comment
Owner

Seq shows System.InvalidOperationException: Response Content-Length mismatch: too many bytes written on GET /api/Profile/images/{id}, thrown from ResponseCompressionBody.FinishCompressionAsync(). Anonymous users; the visitor gets a 500 instead of an avatar.

Confirmed

Both failing objects are already-compressed formats, and the body came out LARGER than the declared Content-Length:

Object Stored type Declared Written Delta
6a5d78ca… image/png 658,063 737,280 +12.04%
69da67d2… image/avif 163,409 163,840 +0.26%

Verified live that these responses really are compressed:

$ curl -sI -H 'Accept-Encoding: br, gzip' https://api.spikersoft.com/api/Profile/images/<id>
content-encoding: gzip
content-type: image/avif
vary: Accept-Encoding
vary: Accept-Encoding      <-- duplicated

Compressing an image cannot shrink it and routinely grows it (framing overhead on incompressible data; worse at CompressionLevel.Fastest). The response declares Content-Length from the stored object, so the overflow makes Kestrel abort.

Also worth noting

The duplicated Vary: Accept-Encoding is two layers compressing: Traefik already runs a gzip-compress middleware on this router (spikersoft-infrastructure/spikersoft-backend/docker-stack.yml:107,116) AND ASP.NET has AddResponseCompression. Text responses were being compressed twice. Not harmful on its own, but redundant CPU on every request — worth deciding which layer should own it.

Fix

options.ExcludedMimeTypes for image/video/audio/font and the common already-compressed binaries. ExcludedMimeTypes wins over MimeTypes, so it stays correct however the default set evolves. Traefik still compresses text at the edge, so nothing loses compression.

Covered by ResponseCompressionExclusionTests, which drives the real ResponseCompressionProvider (not the options object) so it asserts the actual verdict.

Seq shows `System.InvalidOperationException: Response Content-Length mismatch: too many bytes written` on `GET /api/Profile/images/{id}`, thrown from `ResponseCompressionBody.FinishCompressionAsync()`. Anonymous users; the visitor gets a 500 instead of an avatar. ## Confirmed Both failing objects are already-compressed formats, and the body came out LARGER than the declared Content-Length: | Object | Stored type | Declared | Written | Delta | |---|---|---|---|---| | `6a5d78ca…` | image/png | 658,063 | 737,280 | **+12.04%** | | `69da67d2…` | image/avif | 163,409 | 163,840 | **+0.26%** | Verified live that these responses really are compressed: ``` $ curl -sI -H 'Accept-Encoding: br, gzip' https://api.spikersoft.com/api/Profile/images/<id> content-encoding: gzip content-type: image/avif vary: Accept-Encoding vary: Accept-Encoding <-- duplicated ``` Compressing an image cannot shrink it and routinely grows it (framing overhead on incompressible data; worse at `CompressionLevel.Fastest`). The response declares Content-Length from the stored object, so the overflow makes Kestrel abort. ## Also worth noting The duplicated `Vary: Accept-Encoding` is two layers compressing: Traefik already runs a `gzip-compress` middleware on this router (`spikersoft-infrastructure/spikersoft-backend/docker-stack.yml:107,116`) AND ASP.NET has `AddResponseCompression`. Text responses were being compressed twice. Not harmful on its own, but redundant CPU on every request — worth deciding which layer should own it. ## Fix `options.ExcludedMimeTypes` for image/video/audio/font and the common already-compressed binaries. ExcludedMimeTypes wins over MimeTypes, so it stays correct however the default set evolves. Traefik still compresses text at the edge, so nothing loses compression. Covered by `ResponseCompressionExclusionTests`, which drives the real `ResponseCompressionProvider` (not the options object) so it asserts the actual verdict.
Author
Owner

Resolved in spikersoft-backend PR #483. Verified against origin/master:

  • options.ExcludedMimeTypes configured at SpikerSoft.Api/Program.cs:129-140, covering image/*, video/*, audio/*, font/*, zip, gzip, 7z, pdf and model/gltf-binary, with the rationale written inline at :114-128.
  • ResponseCompressionExclusionTests.cs drives the real ResponseCompressionProvider (:58, :66) rather than asserting against a config literal, and covers both directions: the types that were 500-ing and the ones that must stay compressed.

The Traefik double-compression paragraph in the ticket was framed as "worth deciding" rather than acceptance, and is deliberately untouched. If you want it settled, it needs its own ticket.

One durability note for whoever touches this next: the test's excluded-type list at :41-52 is a hand-copied literal with no link back to Program.cs, so the two can drift silently. Adding a type to Program.cs without updating the test would pass.

Closing.

Resolved in spikersoft-backend PR #483. Verified against `origin/master`: - `options.ExcludedMimeTypes` configured at `SpikerSoft.Api/Program.cs:129-140`, covering `image/*`, `video/*`, `audio/*`, `font/*`, zip, gzip, 7z, pdf and `model/gltf-binary`, with the rationale written inline at `:114-128`. - `ResponseCompressionExclusionTests.cs` drives the **real** `ResponseCompressionProvider` (`:58`, `:66`) rather than asserting against a config literal, and covers both directions: the types that were 500-ing and the ones that must stay compressed. The Traefik double-compression paragraph in the ticket was framed as "worth deciding" rather than acceptance, and is deliberately untouched. If you want it settled, it needs its own ticket. One durability note for whoever touches this next: the test's excluded-type list at `:41-52` is a hand-copied literal with no link back to `Program.cs`, so the two can drift silently. Adding a type to `Program.cs` without updating the test would pass. Closing.
Sign in to join this conversation.