1use std::ops::Range;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::OnceLock;
34use std::time::Duration;
35
36use bytes::Bytes;
37
38use crate::error::{Error, Result};
39use crate::source::ByteSource;
40
41#[derive(Debug, Clone)]
42pub struct RetryPolicy {
43 pub max_retries: u32,
46 pub delay: Duration,
47 pub max_delay: Duration,
48 pub connect_timeout: Duration,
49 pub timeout: Duration,
55}
56
57impl Default for RetryPolicy {
58 fn default() -> Self {
59 Self {
60 max_retries: 3,
61 delay: Duration::from_millis(500),
62 max_delay: Duration::from_secs(30),
63 connect_timeout: Duration::from_secs(10),
64 timeout: Duration::from_secs(600),
65 }
66 }
67}
68
69#[derive(Debug)]
70pub struct UrlSource {
71 url: String,
72 agent: ureq::Agent,
73 policy: RetryPolicy,
74 len: OnceLock<u64>,
77 closed: AtomicBool,
78}
79
80#[derive(Debug)]
82struct Response {
83 status: u16,
84 body: Vec<u8>,
85 content_length: Option<u64>,
86 content_range: Option<String>,
89 retry_after: Option<Duration>,
90}
91
92impl UrlSource {
93 pub fn open(url: &str) -> Result<Self> {
94 Self::open_with(url, RetryPolicy::default())
95 }
96
97 pub fn open_with(url: &str, policy: RetryPolicy) -> Result<Self> {
98 let agent: ureq::Agent = ureq::Agent::config_builder()
99 .timeout_connect(Some(policy.connect_timeout))
100 .timeout_global(Some(policy.timeout))
101 .http_status_as_error(false)
105 .user_agent(concat!("gwseq-io/", env!("CARGO_PKG_VERSION")))
106 .build()
107 .into();
108 Ok(Self {
109 url: url.to_string(),
110 agent,
111 policy,
112 len: OnceLock::new(),
113 closed: AtomicBool::new(false),
114 })
115 }
116
117 fn check_open(&self) -> Result<()> {
118 if self.closed.load(Ordering::Relaxed) {
119 return Err(Error::Closed {
120 path: self.url.clone(),
121 });
122 }
123 Ok(())
124 }
125
126 fn http_error(&self, status: Option<u16>, message: impl Into<String>) -> Error {
127 Error::Http {
128 url: self.url.clone(),
129 status,
130 message: message.into(),
131 }
132 }
133
134 fn attempt(
148 &self,
149 range: Option<Range<u64>>,
150 head: bool,
151 cap: u64,
152 ) -> std::result::Result<Response, ureq::Error> {
153 let mut request = if head {
154 self.agent.head(&self.url)
155 } else {
156 self.agent.get(&self.url)
157 };
158 if let Some(range) = &range {
159 request = request.header("Range", format!("bytes={}-{}", range.start, range.end - 1));
160 }
161 let mut response = request.call()?;
162
163 let status = response.status().as_u16();
164 let header = |name: &str| {
165 response
166 .headers()
167 .get(name)
168 .and_then(|v| v.to_str().ok())
169 .map(str::to_string)
170 };
171 let content_length = header("content-length").and_then(|v| v.parse().ok());
172 let content_range = header("content-range");
173 let retry_after = header("retry-after")
176 .and_then(|v| v.trim().parse::<u64>().ok())
177 .map(Duration::from_secs);
178
179 let unusable_range = range
180 .as_ref()
181 .is_some_and(|r| !range_response_is_usable(status, content_length, r));
182 let body = if head || status >= 400 || unusable_range {
183 Vec::new()
184 } else {
185 use std::io::Read as _;
191 let mut buf = Vec::new();
192 response
193 .body_mut()
194 .with_config()
195 .reader()
196 .take(cap)
197 .read_to_end(&mut buf)?;
198 buf
199 };
200 Ok(Response {
201 status,
202 body,
203 content_length,
204 content_range,
205 retry_after,
206 })
207 }
208
209 fn perform(
211 &self,
212 what: &str,
213 range: Option<Range<u64>>,
214 head: bool,
215 cap: u64,
216 ) -> Result<Response> {
217 self.check_open()?;
218 let mut attempt = 0u32;
219 loop {
220 let outcome = self.attempt(range.clone(), head, cap);
221 let (transient, retry_after, failure) = match &outcome {
222 Ok(response) if response.status < 400 => {
223 return Ok(outcome.expect("checked Ok above"))
224 }
225 Ok(response) => (
226 is_retryable_status(response.status),
227 response.retry_after,
228 self.http_error(
229 Some(response.status),
230 format!("{what} failed with HTTP {}", response.status),
231 ),
232 ),
233 Err(error) => (
234 is_retryable_transport(error),
235 None,
236 self.http_error(None, format!("{what} failed: {error}")),
237 ),
238 };
239
240 if attempt >= self.policy.max_retries || !transient {
241 return Err(failure);
242 }
243 std::thread::sleep(self.backoff(attempt, retry_after));
244 attempt += 1;
245 }
246 }
247
248 fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
254 if let Some(after) = retry_after {
255 return after.min(self.policy.max_delay);
256 }
257 let delay = self
258 .policy
259 .delay
260 .saturating_mul(1u32 << attempt.min(16))
261 .min(self.policy.max_delay);
262 let half = delay.as_nanos() as u64 / 2;
263 Duration::from_nanos(half + jitter(half + 1))
264 }
265
266 fn len_from_range(&self) -> Result<u64> {
273 let response = self.perform("determining the size of", Some(0..1), false, 1)?;
274 let total = response
275 .content_range
276 .as_deref()
277 .and_then(parse_content_range_total)
278 .or(if response.status == 200 {
284 response.content_length
285 } else {
286 None
287 })
288 .ok_or_else(|| {
289 self.http_error(
290 Some(response.status),
291 "no content length, and no total in the content range either",
292 )
293 })?;
294 Ok(total)
295 }
296
297 fn check_range_response(&self, response: &Response, range: &Range<u64>) -> Result<()> {
300 if range_response_is_usable(response.status, response.content_length, range) {
301 return Ok(());
302 }
303 Err(self.http_error(
304 Some(response.status),
305 format!(
306 "the server ignored the Range header and answered bytes {}-{} with the whole \
307 resource, so the bytes it sent are not the ones asked for",
308 range.start,
309 range.end - 1
310 ),
311 ))
312 }
313}
314
315fn parse_content_range_total(header: &str) -> Option<u64> {
321 header.rsplit_once('/')?.1.trim().parse().ok()
322}
323
324fn range_response_is_usable(status: u16, content_length: Option<u64>, range: &Range<u64>) -> bool {
334 if status == 206 {
335 return true;
336 }
337 status == 200 && range.start == 0 && content_length.is_some_and(|length| range.end >= length)
338}
339
340fn is_retryable_status(status: u16) -> bool {
342 matches!(status, 408 | 425 | 429) || status >= 500
343}
344
345fn is_retryable_transport(error: &ureq::Error) -> bool {
351 use std::io::ErrorKind::*;
352 match error {
353 ureq::Error::Timeout(_) | ureq::Error::ConnectionFailed | ureq::Error::HostNotFound => true,
354 ureq::Error::Io(io) => matches!(
355 io.kind(),
356 TimedOut
357 | ConnectionRefused
358 | ConnectionReset
359 | ConnectionAborted
360 | NotConnected
361 | BrokenPipe
362 | UnexpectedEof
363 | Interrupted
364 | WouldBlock
365 ),
366 _ => false,
367 }
368}
369
370fn jitter(bound: u64) -> u64 {
373 if bound == 0 {
374 return 0;
375 }
376 let nanos = std::time::SystemTime::now()
377 .duration_since(std::time::UNIX_EPOCH)
378 .map(|d| d.subsec_nanos() as u64)
379 .unwrap_or(0);
380 let id = {
383 use std::hash::{Hash, Hasher};
384 let mut hasher = std::collections::hash_map::DefaultHasher::new();
385 std::thread::current().id().hash(&mut hasher);
386 hasher.finish()
387 };
388 (nanos ^ id) % bound
389}
390
391impl ByteSource for UrlSource {
392 fn path(&self) -> &str {
393 &self.url
394 }
395
396 fn len(&self) -> Result<u64> {
397 if let Some(length) = self.len.get() {
398 return Ok(*length);
399 }
400 let length = match self.perform("determining the size of", None, true, 0) {
401 Ok(response) => response.content_length,
402 Err(error) => {
408 let refused_head = matches!(
409 &error,
410 Error::Http {
411 status: Some(405 | 501),
412 ..
413 }
414 );
415 if !refused_head {
416 return Err(error);
417 }
418 None
419 }
420 };
421 let length = match length {
422 Some(length) => length,
423 None => self.len_from_range()?,
424 };
425 Ok(*self.len.get_or_init(|| length))
426 }
427
428 fn read_at(&self, offset: u64, len: usize) -> Result<Bytes> {
429 self.check_open()?;
430 if len == 0 {
431 return Ok(Bytes::new());
432 }
433 let file_size = self.len()?;
437 if offset >= file_size {
438 return Ok(Bytes::new());
439 }
440
441 let range = offset..offset + len as u64;
442 let response = self.perform("reading", Some(range.clone()), false, len as u64)?;
443 self.check_range_response(&response, &range)?;
444
445 let mut body = response.body;
446 body.truncate(len);
449 Ok(Bytes::from(body))
450 }
451
452 fn prefetch(&self, _ranges: &[Range<u64>]) {}
455
456 fn close(&self) {
457 self.closed.store(true, Ordering::Relaxed);
458 }
459}
460
461const MAX_TEXT_BODY: u64 = 64 << 20;
468
469pub fn http_get_text(url: &str) -> Result<String> {
475 let source = UrlSource::open(url)?;
476 let response = source.perform("fetching", None, false, MAX_TEXT_BODY)?;
477 String::from_utf8(response.body).map_err(|_| Error::Http {
478 url: url.to_string(),
479 status: Some(response.status),
480 message: "response was not valid UTF-8".to_string(),
481 })
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn the_statuses_worth_retrying_are_the_documented_ones() {
490 for status in [408, 425, 429, 500, 502, 503, 504, 599] {
491 assert!(is_retryable_status(status), "{status}");
492 }
493 for status in [400, 401, 403, 404, 410, 416, 451] {
494 assert!(!is_retryable_status(status), "{status}");
495 }
496 }
497
498 #[test]
499 fn backoff_doubles_and_stays_inside_its_jitter_window() {
500 let source = UrlSource::open("http://127.0.0.1:1/x").unwrap();
501 for attempt in 0..4u32 {
502 let nominal = (source.policy.delay.as_secs_f64() * 2f64.powi(attempt as i32))
503 .min(source.policy.max_delay.as_secs_f64());
504 let got = source.backoff(attempt, None).as_secs_f64();
505 assert!(
506 got >= nominal / 2.0 - 1e-6 && got <= nominal + 1e-6,
507 "attempt {attempt}: {got} outside [{}, {nominal}]",
508 nominal / 2.0
509 );
510 }
511 }
512
513 #[test]
514 fn backoff_is_capped_however_many_attempts() {
515 let source = UrlSource::open("http://127.0.0.1:1/x").unwrap();
516 for attempt in [10u32, 20, 40] {
517 assert!(source.backoff(attempt, None) <= source.policy.max_delay);
518 }
519 }
520
521 #[test]
522 fn retry_after_wins_over_the_backoff_but_is_still_capped() {
523 let source = UrlSource::open("http://127.0.0.1:1/x").unwrap();
524 assert_eq!(
525 source.backoff(0, Some(Duration::from_secs(7))),
526 Duration::from_secs(7)
527 );
528 assert_eq!(
529 source.backoff(0, Some(Duration::from_secs(9999))),
530 source.policy.max_delay
531 );
532 }
533
534 fn response(status: u16, content_length: Option<u64>) -> Response {
535 Response {
536 status,
537 body: Vec::new(),
538 content_length,
539 content_range: None,
540 retry_after: None,
541 }
542 }
543
544 #[test]
545 fn a_range_answered_with_the_whole_file_is_refused() {
546 let source = UrlSource::open("http://example.invalid/x").unwrap();
547 let err = source
550 .check_range_response(&response(200, Some(1000)), &(4096..8192))
551 .unwrap_err()
552 .to_string();
553 assert!(err.contains("ignored the Range header"), "{err}");
554 assert!(err.contains("bytes 4096-8191"), "{err}");
555 }
556
557 #[test]
558 fn a_206_is_always_fine_and_a_200_only_when_it_is_the_whole_file() {
559 let source = UrlSource::open("http://example.invalid/x").unwrap();
560 assert!(source
561 .check_range_response(&response(206, Some(100)), &(4096..8192))
562 .is_ok());
563 assert!(source
566 .check_range_response(&response(200, Some(1000)), &(0..4096))
567 .is_ok());
568 assert!(source
570 .check_range_response(&response(200, Some(1000)), &(0..500))
571 .is_err());
572 assert!(source
574 .check_range_response(&response(200, None), &(0..4096))
575 .is_err());
576 }
577
578 #[test]
579 fn a_closed_source_refuses_and_names_the_url() {
580 let source = UrlSource::open("http://127.0.0.1:1/x.bigwig").unwrap();
581 source.close();
582 source.close(); let err = source.read_at(0, 4).unwrap_err().to_string();
584 assert!(err.contains("is closed"), "{err}");
585 assert!(err.contains("x.bigwig"), "{err}");
586 }
587
588 #[test]
589 fn a_zero_length_read_never_leaves_the_process() {
590 let source = UrlSource::open("http://127.0.0.1:1/x.bigwig").unwrap();
592 assert!(source.read_at(0, 0).unwrap().is_empty());
593 }
594
595 use std::io::{BufRead, BufReader, Write};
609 use std::net::{TcpListener, TcpStream};
610 use std::sync::atomic::AtomicU64;
611 use std::sync::Arc;
612
613 #[derive(Clone, Copy, Debug)]
615 enum Behaviour {
616 Ranged,
618 IgnoresRange,
622 NoHead,
624 Endless,
627 NoHeadNoRange,
630 }
631
632 struct TestServer {
633 port: u16,
634 written: Arc<AtomicU64>,
637 stop: Arc<AtomicBool>,
638 }
639
640 impl TestServer {
641 fn start(behaviour: Behaviour, body: Vec<u8>) -> Self {
642 let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
643 let port = listener.local_addr().expect("an address").port();
644 let written = Arc::new(AtomicU64::new(0));
645 let stop = Arc::new(AtomicBool::new(false));
646 let (w, s) = (written.clone(), stop.clone());
647 std::thread::spawn(move || {
648 for stream in listener.incoming() {
649 if s.load(Ordering::Relaxed) {
650 break;
651 }
652 let Ok(stream) = stream else { break };
653 let (w, body) = (w.clone(), body.clone());
654 std::thread::spawn(move || {
655 let _ = serve(stream, behaviour, &body, &w);
656 });
657 }
658 });
659 Self {
660 port,
661 written,
662 stop,
663 }
664 }
665
666 fn url(&self) -> String {
667 format!("http://127.0.0.1:{}/x.bigwig", self.port)
668 }
669
670 fn body_bytes_written(&self) -> u64 {
671 self.written.load(Ordering::Relaxed)
672 }
673
674 fn source(&self) -> UrlSource {
677 UrlSource::open_with(
678 &self.url(),
679 RetryPolicy {
680 max_retries: 0,
681 delay: Duration::from_millis(1),
682 max_delay: Duration::from_millis(1),
683 connect_timeout: Duration::from_secs(2),
684 timeout: Duration::from_secs(10),
685 },
686 )
687 .expect("a source")
688 }
689 }
690
691 impl Drop for TestServer {
692 fn drop(&mut self) {
693 self.stop.store(true, Ordering::Relaxed);
694 let _ = TcpStream::connect(("127.0.0.1", self.port));
696 }
697 }
698
699 fn serve(
700 mut stream: TcpStream,
701 behaviour: Behaviour,
702 body: &[u8],
703 written: &AtomicU64,
704 ) -> std::io::Result<()> {
705 let mut reader = BufReader::new(stream.try_clone()?);
706 let mut request = String::new();
707 reader.read_line(&mut request)?;
708 let mut range: Option<(u64, u64)> = None;
709 loop {
710 let mut line = String::new();
711 if reader.read_line(&mut line)? == 0 || line.trim().is_empty() {
712 break;
713 }
714 if let Some(spec) = line.to_ascii_lowercase().strip_prefix("range: bytes=") {
715 if let Some((a, b)) = spec.trim().split_once('-') {
716 if let (Ok(a), Ok(b)) = (a.parse::<u64>(), b.parse::<u64>()) {
717 range = Some((a, b));
718 }
719 }
720 }
721 }
722 let head = request.starts_with("HEAD ");
723 let total = body.len() as u64;
724
725 if head && matches!(behaviour, Behaviour::NoHead | Behaviour::NoHeadNoRange) {
726 return stream
727 .write_all(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n");
728 }
729 if head {
730 return stream.write_all(
731 format!(
732 "HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nAccept-Ranges: bytes\r\n\r\n"
733 )
734 .as_bytes(),
735 );
736 }
737
738 if matches!(behaviour, Behaviour::Endless) {
739 stream.write_all(
741 format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\n\r\n").as_bytes(),
742 )?;
743 let chunk = vec![b'z'; 64 << 10];
744 loop {
745 match stream.write(&chunk) {
746 Ok(0) | Err(_) => return Ok(()),
747 Ok(n) => {
748 written.fetch_add(n as u64, Ordering::Relaxed);
749 if written.load(Ordering::Relaxed) > 256 << 20 {
752 return Ok(());
753 }
754 }
755 }
756 }
757 }
758
759 let ignores = matches!(
760 behaviour,
761 Behaviour::IgnoresRange | Behaviour::NoHeadNoRange
762 );
763 match range {
764 Some((start, end)) if !ignores => {
765 let end = end.min(total.saturating_sub(1));
766 let slice = &body[start as usize..=(end as usize)];
767 stream.write_all(
768 format!(
769 "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\n\
770 Content-Range: bytes {start}-{end}/{total}\r\n\r\n",
771 slice.len()
772 )
773 .as_bytes(),
774 )?;
775 stream.write_all(slice)?;
776 written.fetch_add(slice.len() as u64, Ordering::Relaxed);
777 Ok(())
778 }
779 _ => {
781 stream.write_all(
782 format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\n\r\n").as_bytes(),
783 )?;
784 for piece in body.chunks(64 << 10) {
787 match stream.write(piece) {
788 Ok(0) | Err(_) => return Ok(()),
789 Ok(n) => {
790 written.fetch_add(n as u64, Ordering::Relaxed);
791 }
792 }
793 }
794 Ok(())
795 }
796 }
797 }
798
799 fn payload(n: usize) -> Vec<u8> {
800 (0..n).map(|i| (i % 251) as u8).collect()
801 }
802
803 #[test]
804 fn a_correct_server_serves_the_bytes_that_were_asked_for() {
805 let body = payload(100_000);
806 let server = TestServer::start(Behaviour::Ranged, body.clone());
807 let source = server.source();
808 assert_eq!(source.len().unwrap(), 100_000);
809 for (offset, len) in [(0u64, 16usize), (4096, 4096), (99_990, 10)] {
810 let got = source.read_at(offset, len).unwrap();
811 assert_eq!(&got[..], &body[offset as usize..offset as usize + len]);
812 }
813 assert_eq!(source.len().unwrap(), 100_000);
815 }
816
817 #[test]
818 fn a_server_that_ignores_range_is_refused_without_transferring_the_file() {
819 let body = payload(4 << 20);
822 let server = TestServer::start(Behaviour::IgnoresRange, body);
823 let source = server.source();
824
825 let err = source.read_at(1 << 20, 4096).unwrap_err();
826 let text = err.to_string();
827 assert!(text.contains("ignored the Range header"), "{text}");
828
829 let written = server.body_bytes_written();
833 assert!(
834 written < 1 << 20,
835 "{written} body bytes crossed the socket for a request that was refused"
836 );
837 }
838
839 #[test]
840 fn a_body_larger_than_the_range_asked_for_is_cut_off() {
841 let server = TestServer::start(Behaviour::Endless, payload(64));
845 let source = server.source();
846 let got = source.read_at(0, 64).unwrap();
849 assert_eq!(got.len(), 64, "the read grew past the range it asked for");
850 let written = server.body_bytes_written();
851 assert!(
852 written < 32 << 20,
853 "{written} bytes were taken for a 64-byte read"
854 );
855 }
856
857 #[test]
858 fn a_server_that_refuses_head_still_gives_up_its_length() {
859 let body = payload(54_321);
860 let server = TestServer::start(Behaviour::NoHead, body.clone());
861 let source = server.source();
862 assert_eq!(source.len().unwrap(), 54_321);
864 let got = source.read_at(50_000, 321).unwrap();
865 assert_eq!(&got[..], &body[50_000..50_321]);
866 }
867
868 #[test]
869 fn a_server_with_neither_head_nor_ranges_still_gives_up_its_length() {
870 let server = TestServer::start(Behaviour::NoHeadNoRange, payload(4321));
875 let source = server.source();
876 assert_eq!(source.len().unwrap(), 4321);
877 let err = source.read_at(100, 16).unwrap_err().to_string();
878 assert!(err.contains("ignored the Range header"), "{err}");
879 }
880
881 #[test]
882 fn a_read_past_the_end_is_empty_and_a_read_over_it_is_short() {
883 let body = payload(1000);
884 let server = TestServer::start(Behaviour::Ranged, body.clone());
885 let source = server.source();
886 assert!(source.read_at(1000, 16).unwrap().is_empty());
887 assert!(source.read_at(5000, 16).unwrap().is_empty());
888 let got = source.read_at(990, 100).unwrap();
889 assert_eq!(&got[..], &body[990..]);
890 }
891
892 #[test]
893 fn the_content_range_total_is_read_only_when_it_is_a_number() {
894 assert_eq!(parse_content_range_total("bytes 0-0/12345"), Some(12345));
895 assert_eq!(parse_content_range_total("bytes 0-0/*"), None);
896 assert_eq!(parse_content_range_total("nonsense"), None);
897 assert_eq!(parse_content_range_total(""), None);
898 }
899}