Skip to content

Commit 4a1b2d9

Browse files
committed
fix(runtime): restart downloads after a 416 response
1 parent 82d5853 commit 4a1b2d9

1 file changed

Lines changed: 68 additions & 16 deletions

File tree

crates/vp_js_runtime/src/download.rs

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,36 @@ pub async fn download_file(
8888
.timeout(timeout)
8989
.header(reqwest::header::RANGE, format!("bytes={resume_from}-"))
9090
.send()
91-
.await?
92-
.error_for_status()?;
93-
94-
match classify_range_response(&ranged, resume_from) {
95-
RangeOutcome::Resume { total } => (ranged, Some(total), true),
96-
RangeOutcome::Restart => {
97-
let total = ranged.content_length();
98-
(ranged, total, false)
99-
}
100-
RangeOutcome::Reject => {
101-
// Never consume an inconsistent partial response. Retry
102-
// once without Range so the outer retry loop can progress.
103-
drop(ranged);
104-
let plain = full_response(client.get(url).timeout(timeout).send().await?)?;
105-
let total = plain.content_length();
106-
(plain, total, false)
91+
.await?;
92+
93+
// A range-capable server answers `Range: bytes=<len>-` on an
94+
// already-complete file with `416 Range Not Satisfiable` (there's
95+
// nothing left to send). `error_for_status()` would surface that
96+
// as a hard failure and every retry would resend the same doomed
97+
// Range request against the same full-length file, permanently
98+
// failing instead of restarting. Treat it like an inconsistent
99+
// partial response: drop it and fall back to a plain full request.
100+
if ranged.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
101+
drop(ranged);
102+
let plain = full_response(client.get(url).timeout(timeout).send().await?)?;
103+
let total = plain.content_length();
104+
(plain, total, false)
105+
} else {
106+
let ranged = ranged.error_for_status()?;
107+
match classify_range_response(&ranged, resume_from) {
108+
RangeOutcome::Resume { total } => (ranged, Some(total), true),
109+
RangeOutcome::Restart => {
110+
let total = ranged.content_length();
111+
(ranged, total, false)
112+
}
113+
RangeOutcome::Reject => {
114+
// Never consume an inconsistent partial response. Retry
115+
// once without Range so the outer retry loop can progress.
116+
drop(ranged);
117+
let plain = full_response(client.get(url).timeout(timeout).send().await?)?;
118+
let total = plain.content_length();
119+
(plain, total, false)
120+
}
107121
}
108122
}
109123
} else {
@@ -600,6 +614,44 @@ mod tests {
600614
assert_eq!(tokio::fs::read(target.as_path()).await.unwrap(), full_body);
601615
}
602616

617+
// Regression for a retry after content-integrity failure: the file on
618+
// disk is already the *full* archive (a complete-but-corrupt previous
619+
// attempt), so the resume offset equals the file's total length. A
620+
// range-capable server answers that with `416 Range Not Satisfiable`
621+
// instead of `206`. Before the fix, `error_for_status()` turned that into
622+
// a hard error and every retry re-sent the same doomed Range request
623+
// against the same full-length file, so the download could never recover.
624+
#[tokio::test]
625+
async fn full_length_file_gets_416_and_restarts_with_a_plain_request() {
626+
let server = MockServer::start();
627+
let dir = TempDir::new().unwrap();
628+
let target = temp_target(&dir);
629+
630+
let full_body = b"the-full-archive-bytes-already-on-disk-from-a-prior-attempt";
631+
// Simulate a complete-but-corrupt archive already on disk (e.g. one
632+
// that failed hash verification and is being retried by the outer
633+
// content-integrity loop in `runtime.rs`).
634+
tokio::fs::write(target.as_path(), full_body).await.unwrap();
635+
636+
let range_mock = server.mock(|when, then| {
637+
when.method(GET)
638+
.path("/archive.bin")
639+
.header("range", format!("bytes={}-", full_body.len()));
640+
then.status(416);
641+
});
642+
let plain_mock = server.mock(|when, then| {
643+
when.method(GET).path("/archive.bin");
644+
then.status(200).header("content-length", full_body.len().to_string()).body(full_body);
645+
});
646+
647+
let url = format!("{}/archive.bin", server.base_url());
648+
download_file(&url, &target, "Downloading test").await.unwrap();
649+
650+
range_mock.assert_hits(1);
651+
plain_mock.assert_hits(1);
652+
assert_eq!(tokio::fs::read(target.as_path()).await.unwrap(), full_body);
653+
}
654+
603655
#[tokio::test]
604656
async fn mismatched_content_range_falls_back_to_a_plain_request() {
605657
let server = MockServer::start();

0 commit comments

Comments
 (0)