Repository navigation
Description
Describe the bug
GET /media/{sha256} with a Range header fails when media storage is Cloudflare R2. The relay advertises accept-ranges: bytes on the full response, so clients trust it and then get an error:
Range: bytes=0-1023→500{"error":"internal error"}Range: bytes=0-99→416
A request with no Range header returns 200 with the complete body, so the object and the key resolve correctly.
This breaks video playback entirely, since players use range requests to stream and seek. Images are unaffected because they are fetched whole.
Steps to reproduce
- Configure the relay with
BUZZ_S3_ENDPOINTpointing at a Cloudflare R2 bucket (https://<account>.r2.cloudflarestorage.com). - Upload a video through Buzz Desktop.
curl -r 0-1023 https://<relay>/media/<sha256>→500.curl https://<relay>/media/<sha256>(no Range) →200, full body.
Expected behavior
206 Partial Content with Content-Range: bytes 0-1023/<total>, per the behaviour documented at crates/buzz-relay/src/api/media.rs (RFC 9110 §14.2).
Version and platform
- Buzz version: relay
ghcr.io/block/buzz:sha-63496cc - OS: Ubuntu 24.04, Docker Compose, media on Cloudflare R2
Logs / additional context
Isolated to the relay — R2 itself handles ranges correctly:
| Check | Result |
|---|---|
R2 ranged GetObject (aws-cli, same key/creds) |
ContentRange: bytes 0-1023/9896517, 1024 bytes |
R2 HeadObject |
ContentLength=9896517, AcceptRanges=bytes |
Sidecar _meta/**.json |
"size": 9896517 (correct) |
Relay full GET |
200, all 9896517 bytes |
Relay ranged GET |
500 |
Both relay paths resolve the same key via resolve_s3_key and both call head_with_metadata, so key resolution and total size are not the difference. The only divergence is in buzz-media::storage:
get_stream→bucket.get_object_stream(key)— worksget_range→bucket.get_object_range(key, start, Some(end))— fails
A get_range failure surfaces as MediaError::StorageError, which matches the 500. This looks like a rust-s3 get_object_range incompatibility with R2 rather than a bug in the range parsing, which behaves correctly.
Relay log for the failing request:
level=ERROR message="response failed" classification="Status code: 500 Internal Server Error" latency="1206 ms" target=tower_http::trace::on_failure http.request.method=GET
Not reproduced against MinIO or AWS S3 — R2 is the only backend tested here, so this may be R2-specific S3 interop. Possibly adjacent to #2470 (S3-interop failure on GCS), though that is the git object store rather than media.
Activity
iroiro147 commentedon Jul 31, 2026
Picking this up. Clear repro + identified seam at
crates/buzz-relay/src/api/media.rs. I'll check the Range parsing/satisfiability logic + the S3 client GetObject range forwarding, fail-first a test pinning the 500/416 paths, then ship the minimal fix. Intent: no behavior change on the happy path ( full body), only correct the ranged branch. Will comment with the PR once opened.iroiro147 commentedon Jul 31, 2026
Diagnosis so far — need one log line to pin it
Thanks for the detailed report. I've traced this end-to-end on current main (
d88313f36, identical relay/media code to yoursha-63496ccdeploy), and there's exactly one unresolved step.What the relay actually does (
crates/buzz-relay/src/api/media.rsserve_blob_for_tenant, range branch):key(your_meta/**.jsonread,size=9896517, is fine).total = head_with_metadata(key)→HEADon the object → readsContent-Length.parse_byte_range("bytes=0-1023", total)→(0, 1023).0 < total⇒ not the 416 branch.endis clamped to ≤min(total-1, start+16MiB-1)⇒get_range(key, 0, 1023)→
bucket.get_object_rangesendsRange: bytes=0-1023(correctly SigV4-signed,rangeinSignedHeaders).head_with_metadata's?andget_range's?are the onlyMediaError::StorageErrorproducers in this path, andStorageErroris the only variant that returns HTTP 500.So on the code as written:
bytes=0-1023can only 500 via step 6.bytes=0-99→ 416 can only happen ifparse_byte_rangereturnedNone, which forstart=0, total=9896517is impossible — unlesstotalisn't actually 9896517 (i.e., the HEADContent-Lengthisn't what the sidecar claims) or the 416 is being generated by something else.The one thing I need: when you reproduce this, the relay logs a line:
That
error=string is the decisive evidence — it carries the HTTP status and R2's XML error body (e.g.HttpFailWithBody(416, "<Error>…InvalidRange…</Error>")vs a HEAD failure), which tells us whether the 500 is the GET or the HEAD, and whether R2 is rejecting the signed range or the HEAD is returning a wrong/zeroContent-Length.Could you paste that log line (with
RUST_LOG=buzz_relay=debugif needed) for thebytes=0-1023→ 500 case, and confirm the response headers on thebytes=0-99→ 416 (specifically anyContent-Range)?I have a narrow fix staged (map
HttpFailWithBody(416)→ relay 416, and normalize the open-ended-range end before it hits R2), but I don't want to ship it blind against a guess about R2's wire behavior.iroiro147 commentedon Aug 1, 2026
I've opened PR #4079 which I believe addresses this.
What's in the PR
Diagnostic fix first: the original report couldn't easily tell us why S3 range GETs were failing —
rust-s3wraps upstream non-2xx responses inS3Error::HttpFailWithBody(status, body), but buzz was collapsing that into a genericStorageError, producing the opaque500 {"error":"internal error"}with no trace of the upstream status or body. That's why the request for logs in this issue didn't yield anything actionable. The PR now logstracing::warn!(s3_status, s3_body, key, start, end)on that path, so any future R2/S3 range rejection is directly visible.Fallback for the 500: when the S3 range GET fails for a reason other than 404, we now fall back to a full object
GETand slice[start..(end+1).min(len)]in memory, returning the usual 206 +Content-Range(or 416 when out of bounds). This unwires theS3 range rejected → 500fast-path entirely. Response headers, chunking, and the existingMAX_RANGE_CHUNKbound are unchanged on the happy path.Tests: unit tests covering the slice math, clamping behaviour at
MAX_RANGE_CHUNK, and a regression case matching your 9,896,517-byte object.Given you verified the same range works against R2 via
aws s3apidirectly, the most likely culprit was a non-range error (e.g. auth/expiry on that specific request, or R2 returning something non-2xx in an edge case) being silently discarded. With this PR it'll either (a) work transparently via the fallback and you'll see the reason in logs, or (b) surface the real upstream error. Happy to adjust the approach if maintainers prefer strict range-only fail-fast — the diagnostic logging is independently worth shipping either way.eastriverlee commentedon Aug 14, 2026
The difference between the working and failing paths is two signed headers, and it sits in
rust-s3rather than in the range parsing.Captured from
rust-s30.37.2 (the versionbuzz-mediapins) against a logging endpoint, same bucket, same key:A body-less
GETis sent withcontent-length: 0andcontent-type: text/plain, and both are signed. The seam is the header assembly ins3/src/request/request_trait.rs:Command::GetObjectis exempt.Command::GetObjectRangeis not, so it falls through to_and picks up both headers, whichcommand.rssupplies as0and"text/plain". That is whyget_streamworks andget_rangedoes not, even though both resolve the same key and both callhead_with_metadatafirst.durch/rust-s3#466 reports the same shape for
DeleteObject: AWS tolerates the extra signed headers, R2 enforces SigV4 strictly and answers403 SignatureDoesNotMatch, so no object can ever be deleted there whilePUT,GETandHEADare fine. durch/rust-s3#470 asks for the general fix.What I have not been able to check is the response R2 gives here, since I have no R2 bucket. A
403from the range GET would reachserve_blob_for_tenantasMediaError::StorageErrorand surface as the500 {"error":"internal error"}in this report, so the two are consistent, and thewarn!(s3_status, s3_body, …)added in #4079 should print it directly. If it readsSignatureDoesNotMatch, the cause is the two headers above and the fix belongs upstream; the full-object fallback in that PR would keep working but would fetch a whole video to serve one range of it.For what it is worth, Supabase Storage accepts the ranged GET with those headers signed (
206, correctContent-Range), so the strictness is not universal among S3-compatible backends.Separately, that same
rust-s3version cannot talk to Supabase Storage at all:Region::hostkeeps the endpoint path andhost_headerpasses it to theHostheader, sohttps://<project>.supabase.co/storage/v1/s3produceshost: <project>.supabase.co/storage/v1/s3and a bare400. Filed as durch/rust-s3#473 with a fix in durch/rust-s3#474, in case anyone here tries that backend.fix(media): treat R2 403 SignatureDoesNotMatch as a range rejection
iroiro147 commentedon Aug 17, 2026
@eastriverlee — this is the answer, and it holds against the source this repo actually builds.
I checked your reading against the pinned fork rather than crates.io
rust-s30.37.2, sincebuzz-mediapinstlongwell-block/rust-s3atc9fce362. Same shape there —s3/src/request/request_trait.rs, the header block afterheaders.insert(HOST, …):Command::GetObjectRangeis absent from the exempt arm, so it falls to_and signscontent-length: 0andcontent-type: text/plain. Worth noting it is exempted in the other match in the same file (the one around the body/payload decision), which is probably why the omission survived — it reads as handled.Your analysis exposed a real gap in #4079, which I have now fixed. That PR gated the full-download fallback on 400/416 only, on the reasoning that 401/403 are auth failures that would fail the full GET too. That reasoning is right in general and wrong here: R2 answers 403
SignatureDoesNotMatchfor this, so the fallback never fired for the backend the issue was actually reported against. The PR logged the status and then returned the same opaque 500.Fixed by gating that 403 on the error code in the body:
AccessDeniedandInvalidAccessKeyIdkeep propagating as errors, so a genuine credential problem does not buy a full-object scan per request, and the existing 16 MiB object cap still bounds the fallback — which is the cost you flagged. Test covers both directions.That said, I agree the fallback is the wrong place for the real fix. It is a correctness net for arbitrary S3-compatible backends, not a fix for this one: serving a range by fetching the whole object is the wrong shape even when capped. Since the pin is a block-owned fork, the one-line exemption of
Command::GetObjectRangecan land there directly without waiting on durch/rust-s3#470, and then this path stops being exercised on R2 at all. I have not opened that PR — it is a different repo and not mine to route.I have no R2 bucket either, so the
SignatureDoesNotMatchbody on this exact call is inferred from your capture plus the header assembly above, not observed. If anyone here can hit a real R2 bucket, thewarn!(s3_status, s3_body, key, start, end)in #4079 prints it directly and settles it.Thanks for the durch/rust-s3#473/#474 note on
Region::hostas well — that one is not in play for this issue but is a real trap for anyone pointingbuzz-mediaat Supabase Storage.fix(media): treat R2 403 SignatureDoesNotMatch as a range rejection
muphy commentedon Oct 5, 2026
Confirmed on a real Cloudflare R2 bucket (self-hosted relay,
ghcr.io/block/buzz:mainpulled 2026-10-03, storage = R2).Relay, same blob (3,941,902-byte mp4):
GET /media/<sha>.mp4(no Range) →200GET /media/<sha>.mp4withRange: bytes=0-1023→500 {"error":"internal error"}(images too). On Android the in-app video player shows "failed to load" because it always sends Range.Directly against the R2 bucket with boto3 (SigV4), same key:
206,Content-Range: bytes 0-1023/3941902Content-Length: 0andContent-Type: text/plainadded before signing (whatGetObjectRangesends per the analysis above) →403 SignatureDoesNotMatchSo the
SignatureDoesNotMatchinference holds on R2: exemptingCommand::GetObjectRangefrom the content-length/content-type arm in the pinned rust-s3 fork should fix it at the source, and #4079's 403 gate would catch it in the meantime.justbry commentedon Oct 8, 2026
Confirming this on another R2-backed relay (
ghcr.io/block/buzz:sha-12670bd), with the storage error captured and a one-line fix verified.Error from R2 (same bucket, key and credentials as the relay,
rust-s3 =0.37.2, featurestokio-rustls-tls, fail-on-err, tags, path style):R2's canonical request in the error body has
content-length:empty, while the client signedcontent-length:0. In 0.37.2GetObjectRangefalls into the default arm of the header block inrequest_trait.rsand getsContent-Length: 0andContent-Type: text/plain, both signed;GetObjectdoes not. R2 does not see aContent-Lengthvalue on a GET. With curl and SigV4, a ranged GET is 206, and adding a signedContent-Length: 0to it makes it 403;Content-Typealone is fine.Fix:
rust-s30.38.0 (2026-10-04) no longer adds those two headers for GET, HEAD or DELETE. The same test program with only the dependency changed to=0.38.0returns 206 for bothget_object_range(key, 0, Some(99))andget_object_range(key, 0, None)against the same R2 object. So bumpingcrates/buzz-media/Cargo.tomlfrom0.37to0.38should close this without a fallback path. I have not built the workspace with 0.38, so theaws-credsfork pin in the rootCargo.tomlmay need a look.Why nobody could find it in the logs: the error is logged by
buzz_media("media storage error"), and the default log filter isbuzz_relay=info, so the line is dropped unlessRUST_LOGalso namesbuzz_media. Either widening the default filter or logging the S3 status inbuzz_relay's range branch would have shown the 403.Garage accepts the extra signed header (the same relay image on Garage answers 206), and presumably MinIO does too, which would be why this passes in tests and fails only on R2.
justbry commentedon Oct 13, 2026
I have opened #8203 for this. It bumps rust-s3 from 0.37 to 0.38, which stops signing
content-length/content-typeon ranged GETs (the cause described in my earlier comment), and removes the aws-creds fork pin, since durch/rust-s3#449 is now released in aws-creds 0.40.0.Verified: a new test that records the request on the wire fails on 0.37.2 and passes on 0.38.0; the media e2e suites, including the 206 and 416 range tests, pass against a local relay; workspace build, clippy and fmt pass. The pull request also handles two other 0.38 changes: whole-blob transfers keep no request deadline (0.38 would cut off downloads that take longer than 60 seconds), and media
put/deleteretry once on transient errors, as 0.37 did.Not verified, and where I would appreciate a maintainer's help: the workspace build itself was not run against R2, and the EKS Pod Identity path needs checking on your cluster now that the fork pin is gone. The description lists what matches and what differs between the fork and aws-creds 0.40.0.
If you have time to review it, I would be grateful. Thank you.