fix: give covers their own 10 MiB byte cap (#71)

The cover fetch reused maxBodyBytes, the 4 MiB ceiling sized for series
pages, so any cover above it was rejected, logged, and retried forever
while the Series kept a monogram. Measured against asurascans on
2026-08-17 that is not an edge case: p90 is 4.52 MB and 3 of 25 covers
exceed 4 MiB, two of them plain JPEGs rather than the 8.57 MB animated
GIF the issue names.

Covers now have maxCoverBytes = 10 MiB, separate from the page cap: a
cover is one bounded binary asset, the page cap still has 3.5x headroom
over measured pages and should not be loosened along with it. 10 MiB is
~18% over the largest cover observed and matches the GitHub and Discord
image limits. The format offers no help in picking the number — GIF has
no maximum size at all — so docs/research/gif-maximum-byte-size.md
records the spec reading, the decoder behaviour, and the live
distribution the cap is derived from.

Transcoding was rejected: decoding is the OOM path, since Go's
image/gif allocates width x height per frame with no dimension guard
and a legal 65535^2 GIF would ask for ~4.29 GB on a 1974 MiB swapless
host.
This commit is contained in:
2026-08-17 12:05:42 +07:00
parent 3ac865cd08
commit ac50c428a9
3 changed files with 382 additions and 6 deletions
+24 -1
View File
@@ -171,7 +171,7 @@ func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
var calls int
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxBodyBytes+1))
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxCoverBytes+1))
response.ContentLength = -1
return response, nil
})}
@@ -187,6 +187,29 @@ func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
}
}
// Covers between the series-page cap and the cover cap must be accepted: the
// 4 MiB page cap rejected 12% of asurascans covers (issue #71).
func TestCoverFetcherAcceptsCoverOverPageCap(t *testing.T) {
body := bytes.Repeat([]byte("x"), maxBodyBytes+1)
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return coverResponse(http.StatusOK, "image/gif", "", body), nil
})}
fetcher := newCoverFetcher(client, func(context.Context, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
})
got, contentType, err := fetcher.Fetch(context.Background(), "https://cdn.example/big.gif")
if err != nil {
t.Fatalf("Fetch rejected a %d-byte cover: %v", len(body), err)
}
if len(got) != len(body) {
t.Fatalf("body = %d bytes, want %d", len(got), len(body))
}
if contentType != "image/gif" {
t.Fatalf("content type = %q, want image/gif", contentType)
}
}
func TestCoverFetcherRejectsNonImage(t *testing.T) {
var calls int
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {