Skip to content

Commit 4d042dc

Browse files
authored
fix(retry): respect range header on retry (#690)
* fix(retry): respect range header on retry * clippy
1 parent 0c380a1 commit 4d042dc

1 file changed

Lines changed: 95 additions & 1 deletion

File tree

src/client/get.rs

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,48 @@ impl<T: GetClient> GetContext<T> {
244244
return Err(Self::err(e));
245245
}
246246

247-
body = retry_body;
247+
// Validate the Content-Range of the retry response
248+
let content_range =
249+
parse_range(&parts.headers).map_err(Self::err)?;
250+
let actual = content_range.range;
251+
252+
// Exact match — use body as-is
253+
if actual == range {
254+
body = retry_body;
255+
} else if actual.start <= range.start && actual.end >= range.end
256+
{
257+
// Received range is a superset for requested content,
258+
// skip leading bytes to align to the needed offset.
259+
let skip = (range.start - actual.start) as usize;
260+
let mut skipped = 0;
261+
let mut retry_body = retry_body;
262+
while skipped < skip {
263+
let frame = retry_body.frame().await
264+
.ok_or_else(|| Self::err(GetResultError::UnexpectedRange {
265+
expected: range.clone(), actual: actual.clone(),
266+
}))?
267+
.map_err(Self::err)?;
268+
let Some(bytes) = frame.into_data().ok() else { continue };
269+
let remaining = skip - skipped;
270+
if bytes.len() <= remaining {
271+
skipped += bytes.len();
272+
} else {
273+
let keep = bytes.slice(remaining..);
274+
range.start += keep.len() as u64;
275+
body = retry_body;
276+
let etag = Some(etag.clone());
277+
return Ok(Some((keep, (ctx, body, etag, range))));
278+
}
279+
}
280+
body = retry_body;
281+
} else {
282+
return Err(Self::err(
283+
GetResultError::UnexpectedRange {
284+
expected: range,
285+
actual,
286+
},
287+
));
288+
}
248289
}
249290
(Err(e), _) => return Err(Self::err(e)),
250291
}
@@ -772,4 +813,57 @@ mod http_tests {
772813
"Generic HTTP error: HTTP error: request or response body error"
773814
);
774815
}
816+
817+
#[tokio::test]
818+
async fn test_retry_validate_content_range() {
819+
let mock = MockServer::new().await;
820+
let retry = RetryConfig {
821+
backoff: Default::default(),
822+
max_retries: 3,
823+
retry_timeout: Duration::from_secs(1000),
824+
};
825+
826+
let options = ClientOptions::new().with_allow_http(true);
827+
let store = HttpBuilder::new()
828+
.with_client_options(options)
829+
.with_retry(retry)
830+
.with_url(mock.url())
831+
.build()
832+
.unwrap();
833+
834+
let path = Path::from("test");
835+
836+
mock.push(
837+
Response::builder()
838+
.header(CONTENT_LENGTH, 10)
839+
.header(ETAG, "abc")
840+
.body(Chunked::new(vec![
841+
Ok(Bytes::from_static(b"hello")),
842+
Err(()),
843+
]))
844+
.unwrap(),
845+
);
846+
847+
mock.push_fn(|req| {
848+
assert_eq!(
849+
req.headers().get(RANGE).unwrap().to_str().unwrap(),
850+
"bytes=5-9"
851+
);
852+
853+
Response::builder()
854+
.status(StatusCode::PARTIAL_CONTENT)
855+
.header(CONTENT_LENGTH, 10)
856+
.header(ETAG, "abc")
857+
.header(CONTENT_RANGE, "bytes 0-9/10")
858+
.body("helloworld".to_string())
859+
.unwrap()
860+
});
861+
862+
let result = store.get(&path).await.unwrap().bytes().await.unwrap();
863+
assert_eq!(
864+
result.as_ref(),
865+
b"helloworld",
866+
"expected correct 10-byte content"
867+
);
868+
}
775869
}

0 commit comments

Comments
 (0)