Feature/resumable http#76
Conversation
There was a problem hiding this comment.
Pull request overview
Adds resilient HTTP(S) streaming by introducing a resumable reader that can recover from mid-stream connection drops using HTTP Range requests, and wires it into the main read and download paths in OneIO.
Changes:
- Introduce
ResumableHttpReader(src/resumable_http.rs) that reconnects and resumes reads viaRange: bytes={offset}-, with validation ofContent-RangeandLast-Modified. - Integrate resumable behavior into
OneIo::get_reader_raw()andOneIo::download()for HTTP(S) paths. - Document the user-facing behavior in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/resumable_http.rs | New resumable HTTP reader implementation plus unit tests simulating dropped connections and Range behavior |
| src/client.rs | Wrap HTTP(S) raw readers and download streams with ResumableHttpReader |
| src/lib.rs | Feature-gated module registration for resumable_http |
| CHANGELOG.md | Notes new resumable HTTP(S) read/download behavior under Unreleased |
| for attempt in 1..=MAX_RETRIES { | ||
| let backoff_ms = BASE_RETRY_DELAY_MS.saturating_mul(1u64 << attempt.min(4)); | ||
| std::thread::sleep(std::time::Duration::from_millis(backoff_ms)); | ||
|
|
||
| let resp = match self | ||
| .client | ||
| .get(&self.url) | ||
| .header("Range", format!("bytes={}-", self.offset)) | ||
| .send() | ||
| { | ||
| Ok(resp) => resp, | ||
| // Couldn't reach the server — back off and try again. | ||
| Err(_) => continue, | ||
| }; |
| std::io::copy(&mut reader, &mut writer)?; | ||
| Ok(()) |
There was a problem hiding this comment.
@digizeph if the AI raised a valid concern you might want to do the same for the ftp feature
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| let response = self.get_http_reader_raw(remote_path)?; | ||
| let mut reader = crate::resumable_http::ResumableHttpReader::new( | ||
| self.http_client().clone(), | ||
| remote_path.to_string(), | ||
| response, | ||
| ); | ||
| std::io::copy(&mut reader, &mut writer)?; |
|
|
||
| let (mut stream2, _) = listener.accept().unwrap(); | ||
| let req = read_request(&mut stream2); | ||
| assert!(req.contains("range: bytes=5-")); |
| // server reports 416 which the reader must treat as a clean EOF. | ||
| let (mut stream2, _) = listener.accept().unwrap(); | ||
| let req = read_request(&mut stream2); | ||
| assert!(req.contains("range: bytes=10-")); |
|
|
||
| let (mut stream2, _) = listener.accept().unwrap(); | ||
| let req = read_request(&mut stream2); | ||
| assert!(req.contains("range: bytes=5-")); |
|
|
||
| let (mut stream2, _) = listener.accept().unwrap(); | ||
| let req = read_request(&mut stream2); | ||
| assert!(req.contains("range: bytes=5-")); |
|
|
||
| let (mut stream2, _) = listener.accept().unwrap(); | ||
| let req = read_request(&mut stream2); | ||
| assert!(req.contains("range: bytes=5-")); |
| fn parse_content_range_start(value: &str) -> Option<u64> { | ||
| // "bytes 5-9/10" -> "5-9/10" -> "5" | ||
| let range = value.strip_prefix("bytes ")?; | ||
| let start = range.split_once('-')?.0; | ||
| start.trim().parse().ok() | ||
| } |
There was a problem hiding this comment.
this is actually true, will do
https://www.rfc-editor.org/rfc/rfc9110.html#section-14.1-4
| return match resp.status() { | ||
| // Offset is at or past end of file. | ||
| reqwest::StatusCode::RANGE_NOT_SATISFIABLE => Ok(Resume::Eof), | ||
| // Server honored the Range request. | ||
| reqwest::StatusCode::PARTIAL_CONTENT => { | ||
| self.accept_resumed_response(resp)?; | ||
| Ok(Resume::Resumed) | ||
| } | ||
| // Anything else means the Range request was ignored. | ||
| _ => Ok(Resume::Unsupported), | ||
| }; |
There was a problem hiding this comment.
A connection dropped before any bytes are successfully delivered is a niche case.
A server replying to a range request with a 200 is also a niche case.
In my opinion it's nitpicking, but the proposed fixed is short... so is it worth keeping it @digizeph?
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod test { |
Closes #74
Summary
Remote HTTP(S) transfers now recover transparently from mid-stream connection
drops. Previously, if a server closed the connection before the full body was
received, the read (or download) failed and the caller had to restart from
scratch. This PR adds a
ResumableHttpReaderthat tracks the byte offset and,on an unexpected connection drop, reconnects with a
Range: bytes={offset}-request and continues from where it left off. Upper layers (decompressors,
line readers, file writers) see a single contiguous stream and are unaffected.
Changes
ResumableHttpReader(src/resumable_http.rs): wraps areqwest::blocking::Response, implementsstd::io::Read, and transparentlyreconnects via Range requests when the underlying stream errors mid-read.
MAX_RETRIES = 5) with exponential backoff, so aserver that repeatedly fails to make progress causes the read to error out
rather than loop forever.
data into the stream:
Content-Rangestart offset must equal the current read offset.Last-Modified(when the original response provided it) must match onthe resumed response.
416 Range Not Satisfiableat the end of a stream is treated as EOF;a
200 OKor any non-206reply to a Range request is treated asunsupported and surfaces the original error.
get_reader_rawinsrc/client.rs) soget_reader,read_to_string,read_lines, etc. allbenefit.
downloadinsrc/client.rs) sodownloadanddownload_with_retryresume mid-transfer instead of onlyretrying from byte 0.
Testing
Added unit tests in
src/resumable_http.rsthat script exact HTTP responsesover a raw
TcpListenerand simulate connection drops:no_drop— normal read with no resume needed.drop_resume— server drops mid-body; reader resumes and returns the fullcontent.
download_resumes_after_drop— end-to-end throughdownload(); the filewritten to disk contains the complete content after a mid-body drop.
range_not_supported_is_err— non-206 reply to a Range request errors.range_oob_is_eof— over-declared Content-Length forces an out-of-rangeresume that returns 416, treated as EOF.
new_last_modified_is_err— resumed response with a differentLast-Modifiedis rejected.
missing_last_modified_on_resume_is_err— resumed response omittingLast-Modifiedis rejected.max_retries_exhausted_is_err— repeated no-progress 206 responses stop afterexactly
MAX_RETRIESattempts.no_data_206— empty-body 206 responses do not loop and error out.Commands run locally:
Checklist
--all-featuresin CI)