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 { |
|
Thanks for putting this together, and for working through the Copilot feedback so carefully. The implementation itself is solid, and the test suite scripting exact server behavior over raw sockets are all exactly what I wanted to see. Before we can merge, though, there's one design point I want to change, plus two correctness issues in the resume logic.
Right now the PR wraps every HTTP(S) read in get_reader_raw and download with the resumable reader, which changes runtime behavior for every existing user on a routine version bump. oneio sits underneath data-ingestion pipelines (bgpkit-parser among them), and a connection drop today is a loud, retryable error at the application level, and after this change it becomes a transparent reconnect with spliced data. Most of the time that's what people want, but the decision should be theirs. What I'd prefer: keep get_reader_raw and download exactly as they are, and expose the resumable reader as an additional API — e.g. a OneIoBuilder::http_resume(true) flag that opts the whole client in, and/or a dedicated get_resumable_reader constructor for the streaming path. Default stays byte-for-byte identical to current behavior. Your use case (concurrent streams with idle-connection drops) opts in explicitly.
In resume(), 416 Range Not Satisfiable maps to Resume::Eof at any offset, and the range_oob_is_eof test locks that in. The problem: today, a server that over-declares Content-Length and then drops produces a hard incomplete-body error. With this change it produces a successful, silently truncated read — and the same happens if a misbehaving server or proxy returns 416 mid-stream for any other reason. For gzip input the CRC probably catches it downstream, but read_to_string/read_lines on plain text would accept partial data with no signal at all. In a data pipeline, silent truncation is much worse than a visible error. I'd only treat 416 as EOF when the current offset is at or past the Content-Length of the original response (when it declared one). If offset is below that, or we don't know the total, a 416 should surface as an error.
Right now the resumed response is validated against the original Last-Modified only, and only when the original response had one. Two gaps: If the original response carried no Last-Modified, there is no validation at all — a resource that changed between the drop and the resume gets silently spliced into a single stream. None of this changes the core design, it's about how it's wired in and how conservative the validation is. Happy to discuss the API shape for the opt-in if you have a preference. Thanks again for the thorough work on this. |
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)