Skip to main content

rete_core/
http.rs

1//! An HTTP(S)-backed [`RangeReader`] for native hosts (SPEC.md §9): point it at
2//! a URL and it fetches only the byte ranges a query needs via HTTP `Range`
3//! requests. Behind the `http` feature, because it brings a network stack; the
4//! CLI, the Python wheels and the R package all read remote files through this
5//! one implementation. The browser engine has its own readers (sync XHR and
6//! `fetch`), which follow the same contract.
7//!
8//! Uses blocking `ureq` with rustls (the `tls` feature), so `http://` and
9//! `https://` both work — the file can live on S3, GitHub, or any CDN that honors
10//! `Range`, which is the format's whole deployment story.
11//!
12//! Remote time is round trips, not bytes, so the reader does three things:
13//!
14//! - **One agent, kept-alive connections.** Every request goes through one
15//!   `ureq::Agent` whose pool keeps up to [`FetchPolicy::concurrency`] idle
16//!   connections to the host. `ureq::get` builds a fresh agent per call, so
17//!   each range used to open (and, on `https://`, TLS-handshake) its own
18//!   connection: one to three extra round trips per request.
19//! - **Batches in parallel.** [`RangeReader::read_many`] (the engine's batch of
20//!   tile or dictionary-chunk faults for one query step) goes out with up to
21//!   [`FetchPolicy::concurrency`] requests in flight, from a work queue, so one
22//!   slow range does not hold up the ranges queued behind it.
23//! - **Nearby ranges coalesced.** Ranges of one batch at most
24//!   [`FetchPolicy::coalesce_gap`] apart are fetched as one request (up to
25//!   [`FetchPolicy::max_span`] long) and sliced apart again.
26//!
27//! A transient failure (a dropped connection, a short body, `408`/`429`/`5xx`)
28//! is retried with exponential backoff; a permanent one (a `404`, a
29//! `Content-Range` for the wrong offset) fails at once. Any range of a batch
30//! failing fails the batch, and the ranges still queued are not started.
31//!
32//! **A host that ignores `Range`** answers `200` with the whole body (a plain
33//! static server, a proxy that strips the header, a CDN serving a cached full
34//! object). That body has crossed the network by the time the reader sees it,
35//! and every later range request would download it again, so when it is the
36//! file — exactly the length the open measured — and at most
37//! [`FetchPolicy::whole_body_cap`] ([`WHOLE_BODY_CAP`], 256 MiB, the browser
38//! readers' cap) it is kept, and every read from then on is a slice of it. A
39//! larger body is refused by name, without being read: the reader never holds
40//! more than the cap of a body nobody asked for. Taking the first `len` bytes
41//! of a `200` as if they were the range is the one thing it never does.
42
43use std::io::Read;
44use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
45use std::sync::{Mutex, OnceLock};
46use std::time::Duration;
47
48use crate::RangeReader;
49
50/// The largest whole body (a `200` to a range request) the reader keeps and
51/// serves reads from — the same 256 MiB the browser readers keep.
52pub const WHOLE_BODY_CAP: u64 = 256 << 20;
53
54/// How [`HttpRangeReader`] fetches: fan-out, coalescing, retries, and the
55/// whole-body cap. [`FetchPolicy::from_env`] reads it once when a reader opens.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct FetchPolicy {
58    /// Requests in flight at once in `read_many` (`RETE_HTTP_CONCURRENCY`,
59    /// 1–64; `1` fetches a batch sequentially). Also what the reader reports
60    /// to the planner as its [`RangeReader::concurrency`].
61    pub concurrency: usize,
62    /// Two ranges of one batch at most this many bytes apart are fetched as
63    /// one request (`RETE_HTTP_COALESCE_KB`; `0` merges only touching or
64    /// overlapping ranges).
65    pub coalesce_gap: u64,
66    /// Coalescing never grows a request past this many bytes, so one huge span
67    /// does not serialize what could have gone out in parallel.
68    pub max_span: u64,
69    /// Extra attempts for a transiently failing request (`RETE_HTTP_RETRIES`,
70    /// at most 10; `0` = no retry).
71    pub retries: u32,
72    /// The first backoff; it quadruples per retry, capped at 2 s.
73    pub backoff_ms: u64,
74    /// `RETE_HTTP_TRACE`: one stderr line per read and per batch, to see which
75    /// ranges a query needed and which of them went out together.
76    pub trace: bool,
77    /// The largest whole body a host that ignores `Range` may send and still
78    /// have it kept ([`WHOLE_BODY_CAP`]); a larger one is refused by name.
79    pub whole_body_cap: u64,
80    /// Bytes the open reads first, as its length probe ([`OPEN_READ`]); `0`
81    /// probes with `HEAD` instead and keeps nothing.
82    pub open_read: u64,
83    /// A file at most this long is fetched whole by the open: the first read
84    /// gives its length, one more read the rest, and no later read leaves
85    /// memory (`RETE_HTTP_WHOLE_MB`, [`WHOLE_FILE_MAX`]; `0` never).
86    pub whole_max: u64,
87}
88
89/// The default [`FetchPolicy::whole_max`]: 8 MiB. On two catalog files of
90/// 7.0 and 7.6 MB a query took 2 round trips this way instead of 11–33, for
91/// 6.8–7.4 MB read instead of 0.8–2.4 MB (34–66% faster at 50 ms).
92pub const WHOLE_FILE_MAX: u64 = 8 << 20;
93
94impl FetchPolicy {
95    /// 16 in flight matches the wasm client's fetch-worker pool and the Node
96    /// client's `reteReadMany` cap; a CDN or S3 serves that many concurrent
97    /// range reads happily.
98    pub const DEFAULT: FetchPolicy = FetchPolicy {
99        concurrency: 16,
100        coalesce_gap: 32 << 10,
101        max_span: 8 << 20,
102        retries: 2,
103        backoff_ms: 100,
104        trace: false,
105        whole_body_cap: WHOLE_BODY_CAP,
106        open_read: OPEN_READ,
107        whole_max: WHOLE_FILE_MAX,
108    };
109
110    /// [`FetchPolicy::DEFAULT`] with the `RETE_HTTP_*` overrides applied.
111    pub fn from_env() -> Self {
112        let num = |k: &str| {
113            std::env::var(k)
114                .ok()
115                .and_then(|v| v.trim().parse::<u64>().ok())
116        };
117        let d = Self::DEFAULT;
118        FetchPolicy {
119            concurrency: num("RETE_HTTP_CONCURRENCY")
120                .map_or(d.concurrency, |n| n.clamp(1, 64) as usize),
121            coalesce_gap: num("RETE_HTTP_COALESCE_KB")
122                .map_or(d.coalesce_gap, |kb| kb.saturating_mul(1024)),
123            max_span: d.max_span,
124            retries: num("RETE_HTTP_RETRIES").map_or(d.retries, |n| n.min(10) as u32),
125            backoff_ms: d.backoff_ms,
126            trace: std::env::var_os("RETE_HTTP_TRACE").is_some(),
127            whole_body_cap: d.whole_body_cap,
128            open_read: d.open_read,
129            whole_max: num("RETE_HTTP_WHOLE_MB")
130                .map_or(d.whole_max, |mb| mb.saturating_mul(1 << 20)),
131        }
132    }
133
134    fn backoff(&self, attempt: u32) -> Duration {
135        let ms = self
136            .backoff_ms
137            .saturating_mul(4u64.saturating_pow(attempt))
138            .min(2000);
139        Duration::from_millis(ms)
140    }
141}
142
143impl Default for FetchPolicy {
144    fn default() -> Self {
145        Self::DEFAULT
146    }
147}
148
149/// Why one request failed: worth another attempt, or not.
150enum Failure {
151    Transient(std::io::Error),
152    Permanent(std::io::Error),
153}
154
155/// `408 Request Timeout`, `429 Too Many Requests` and the 5xx a retry can cure
156/// (not `501 Not Implemented` or `505 HTTP Version Not Supported`).
157fn retryable_status(code: u16) -> bool {
158    code == 408 || code == 429 || ((500..600).contains(&code) && code != 501 && code != 505)
159}
160
161fn classify(err: ureq::Error, what: &str) -> Failure {
162    match err {
163        ureq::Error::Status(code, _) => {
164            let e = std::io::Error::other(format!("HTTP {code} for {what}"));
165            if retryable_status(code) {
166                Failure::Transient(e)
167            } else {
168                Failure::Permanent(e)
169            }
170        }
171        ureq::Error::Transport(t) => {
172            let transient = matches!(
173                t.kind(),
174                ureq::ErrorKind::Io
175                    | ureq::ErrorKind::ConnectionFailed
176                    | ureq::ErrorKind::Dns
177                    | ureq::ErrorKind::ProxyConnect
178            );
179            let e = std::io::Error::other(format!("{t} ({what})"));
180            if transient {
181                Failure::Transient(e)
182            } else {
183                Failure::Permanent(e)
184            }
185        }
186    }
187}
188
189/// A parsed `Content-Range: bytes a-b/N` (or `a-b/*`) value.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191struct ContentRange {
192    start: u64,
193    /// Inclusive, as on the wire.
194    end: u64,
195    /// `None` for `*`.
196    total: Option<u64>,
197}
198
199/// Parse a `Content-Range` value strictly: the `bytes` unit, decimal digits
200/// only, `start ≤ end`, and `end < total` when the total is known. Anything
201/// else is `None` — a malformed value is never half-read.
202fn parse_content_range(v: &str) -> Option<ContentRange> {
203    let rest = v.trim().strip_prefix("bytes ")?.trim_start();
204    let (range, total) = rest.split_once('/')?;
205    let (a, b) = range.split_once('-')?;
206    let num = |x: &str| -> Option<u64> {
207        let x = x.trim();
208        (!x.is_empty() && x.bytes().all(|c| c.is_ascii_digit()))
209            .then(|| x.parse().ok())
210            .flatten()
211    };
212    let (start, end) = (num(a)?, num(b)?);
213    let total = match total.trim() {
214        "*" => None,
215        t => Some(num(t)?),
216    };
217    if start > end || total.is_some_and(|t| end >= t) {
218        return None;
219    }
220    Some(ContentRange { start, end, total })
221}
222
223/// The total of a well-formed `Content-Range` (`None` for `/*` or malformed).
224fn content_range_total(v: &str) -> Option<u64> {
225    parse_content_range(v)?.total
226}
227
228/// One request of a coalesced batch.
229#[derive(Debug, PartialEq, Eq)]
230struct Span {
231    offset: u64,
232    len: u64,
233}
234
235/// Where each requested range of a batch lands: `(request, offset within
236/// that request)`, or `None` for an empty range.
237type Placement = Vec<Option<(usize, u64)>>;
238
239/// Merge a batch's non-empty ranges into the requests that fetch them: sorted
240/// by offset, a range joins the current request when it starts at most `gap`
241/// bytes past its end and the request stays within `max_span`. Returns the
242/// requests and, per input range, where its bytes are.
243fn coalesce(
244    ranges: &[(u64, u64)],
245    gap: u64,
246    max_span: u64,
247) -> std::io::Result<(Vec<Span>, Placement)> {
248    let mut order: Vec<usize> = (0..ranges.len()).filter(|&i| ranges[i].1 > 0).collect();
249    for &i in &order {
250        let (o, l) = ranges[i];
251        if o.checked_add(l).is_none() {
252            return Err(std::io::Error::new(
253                std::io::ErrorKind::InvalidInput,
254                format!("range {o}+{l} overflows"),
255            ));
256        }
257    }
258    order.sort_by_key(|&i| ranges[i]);
259    let mut spans: Vec<Span> = Vec::new();
260    let mut place: Placement = vec![None; ranges.len()];
261    for i in order {
262        let (o, l) = ranges[i];
263        let end = o + l;
264        let joins = spans.last().is_some_and(|s| {
265            let s_end = s.offset + s.len;
266            o <= s_end.saturating_add(gap) && end.max(s_end) - s.offset <= max_span
267        });
268        if joins {
269            let s = spans.last_mut().expect("checked above");
270            s.len = (s.offset + s.len).max(end) - s.offset;
271        } else {
272            spans.push(Span { offset: o, len: l });
273        }
274        let si = spans.len() - 1;
275        place[i] = Some((si, o - spans[si].offset));
276    }
277    Ok((spans, place))
278}
279
280/// The refusal for a whole body over the cap: named, so it reads as the host's
281/// limitation rather than as a broken file.
282fn whole_body_refusal(url: &str, bytes: Option<u64>, cap: u64) -> std::io::Error {
283    let size = match bytes {
284        Some(n) => format!("all {n} bytes"),
285        None => format!("more than {cap} bytes"),
286    };
287    std::io::Error::other(format!(
288        "the host ignored the Range header and sent {size} of {url} (status 200 instead \
289         of 206 Partial Content); a full body over {} MiB is not kept in memory — host the \
290         file where HTTP range requests work",
291        cap >> 20
292    ))
293}
294
295/// One read served from a whole-resource body, bounds-checked: the body is
296/// whatever the host sent, so a read past its end is a clean error.
297fn slice_whole(whole: &[u8], offset: u64, len: u64, url: &str) -> std::io::Result<Vec<u8>> {
298    let end = offset
299        .checked_add(len)
300        .filter(|&end| end <= whole.len() as u64)
301        .ok_or_else(|| {
302            std::io::Error::other(format!(
303                "range {offset}+{len} is past the end of the {} bytes {url} sent",
304                whole.len()
305            ))
306        })?;
307    Ok(whole[offset as usize..end as usize].to_vec())
308}
309
310/// Read a `200` body whole, up to `cap` bytes: `Ok(None)` when it is longer
311/// (the caller refuses it), never more than `cap + 1` bytes in memory.
312fn read_capped(resp: ureq::Response, cap: u64, what: &str) -> Result<Option<Vec<u8>>, Failure> {
313    let declared = resp
314        .header("content-length")
315        .and_then(|v| v.trim().parse::<u64>().ok());
316    if declared.is_some_and(|n| n > cap) {
317        return Ok(None); // refused unread: dropping the response closes it
318    }
319    let mut buf = Vec::with_capacity(declared.unwrap_or(0).min(cap) as usize);
320    resp.into_reader()
321        .take(cap.saturating_add(1))
322        .read_to_end(&mut buf)
323        .map_err(|e| Failure::Transient(std::io::Error::other(format!("{e} ({what})"))))?;
324    if buf.len() as u64 > cap {
325        return Ok(None);
326    }
327    if declared.is_some_and(|n| n != buf.len() as u64) {
328        return Err(Failure::Transient(std::io::Error::other(format!(
329            "short body: got {} of the {} bytes {what} declared",
330            buf.len(),
331            declared.unwrap_or(0)
332        ))));
333    }
334    Ok(Some(buf))
335}
336
337/// What opening learnt: the length, and the whole file when the host ignored
338/// `Range` on the length probe itself.
339struct Probe {
340    len: u64,
341    whole: Option<Vec<u8>>,
342    /// The file's first bytes, when the length came from the opening ranged
343    /// GET: later reads inside them cost nothing.
344    prefix: Option<Vec<u8>>,
345}
346
347/// What the open reads first: the same 64 KiB a `0x06` reader plans from, so
348/// the length probe *is* the first read and no `HEAD` is sent.
349pub const OPEN_READ: u64 = 64 * 1024;
350
351/// An HTTP(S) [`RangeReader`]: kept-alive connections, parallel and coalesced
352/// batches, retries, and the bounded whole-body fallback for hosts that ignore
353/// `Range` (see the module docs).
354pub struct HttpRangeReader {
355    url: String,
356    len: u64,
357    agent: ureq::Agent,
358    policy: FetchPolicy,
359    /// Sent with every request: auth tokens, a custom `User-Agent`.
360    headers: Vec<(String, String)>,
361    /// The whole resource, once a host has answered a range request with `200`
362    /// and a body the reader kept (see the module docs).
363    whole: OnceLock<Vec<u8>>,
364    /// Held while a whole body is read, so concurrent `200`s in one batch keep
365    /// one body, not one per worker.
366    adopting: Mutex<()>,
367    /// The first [`OPEN_READ`] bytes, fetched by the open (empty when the
368    /// length came from a `HEAD`).
369    prefix: Vec<u8>,
370}
371
372impl HttpRangeReader {
373    /// Open `url` under [`FetchPolicy::from_env`], with no extra headers.
374    pub fn open(url: &str) -> std::io::Result<Self> {
375        Self::open_with(url, FetchPolicy::from_env())
376    }
377
378    /// [`open`](Self::open) under an explicit fetch policy.
379    pub fn open_with(url: &str, policy: FetchPolicy) -> std::io::Result<Self> {
380        Self::open_with_headers(url, Vec::new(), policy)
381    }
382
383    /// Open `url`, sending `headers` with every request. The length comes from
384    /// a `HEAD`'s `Content-Length`; a host that omits it, or refuses `HEAD`
385    /// (a presigned GET-only URL), is asked with a `bytes=0-0` GET instead,
386    /// whose `Content-Range` carries the total — and a `200` to that GET is the
387    /// whole file, kept under the whole-body rules.
388    pub fn open_with_headers(
389        url: &str,
390        headers: Vec<(String, String)>,
391        policy: FetchPolicy,
392    ) -> std::io::Result<Self> {
393        let agent = ureq::AgentBuilder::new()
394            // Keep a batch's worth of connections open between batches.
395            .max_idle_connections(policy.concurrency.max(4) * 2)
396            .max_idle_connections_per_host(policy.concurrency)
397            .timeout_connect(Duration::from_secs(30))
398            .timeout_read(Duration::from_secs(120))
399            .build();
400        let mut reader = HttpRangeReader {
401            url: url.to_string(),
402            len: 0,
403            agent,
404            policy,
405            headers,
406            whole: OnceLock::new(),
407            adopting: Mutex::new(()),
408            prefix: Vec::new(),
409        };
410        let probe = reader.probe_len()?;
411        reader.len = probe.len;
412        if let Some(body) = probe.whole {
413            let _ = reader.whole.set(body);
414        }
415        if let Some(p) = probe.prefix {
416            reader.prefix = p;
417        }
418        // A small file: the rest in one more read, and nothing after it.
419        let held = reader.prefix.len() as u64;
420        if reader.whole.get().is_none()
421            && held > 0
422            && reader.len > held
423            && reader.len <= reader.policy.whole_max
424        {
425            let rest = reader.with_retries(|| reader.fetch_once(held, reader.len - held))?;
426            let mut body = std::mem::take(&mut reader.prefix);
427            body.extend_from_slice(&rest);
428            if reader.policy.trace {
429                eprintln!("[http] {}: {} bytes, fetched whole", reader.url, body.len());
430            }
431            let _ = reader.whole.set(body);
432        }
433        Ok(reader)
434    }
435
436    /// The URL this reader fetches.
437    pub fn url(&self) -> &str {
438        &self.url
439    }
440
441    /// Whether the host ignored `Range` and every read is now served from the
442    /// whole body held in memory.
443    pub fn serves_whole_body(&self) -> bool {
444        self.whole.get().is_some()
445    }
446
447    fn request(&self, method: &str) -> ureq::Request {
448        let mut req = self.agent.request(method, &self.url);
449        for (k, v) in &self.headers {
450            req = req.set(k, v);
451        }
452        req
453    }
454
455    /// Run `call` with the policy's retries for transient failures.
456    fn with_retries<T>(&self, mut call: impl FnMut() -> Result<T, Failure>) -> std::io::Result<T> {
457        let mut attempt = 0;
458        loop {
459            match call() {
460                Ok(v) => return Ok(v),
461                Err(Failure::Permanent(e)) => return Err(e),
462                Err(Failure::Transient(e)) if attempt >= self.policy.retries => {
463                    return Err(if attempt == 0 {
464                        e
465                    } else {
466                        std::io::Error::other(format!("{e} (after {} attempts)", attempt + 1))
467                    });
468                }
469                Err(Failure::Transient(_)) => {
470                    std::thread::sleep(self.policy.backoff(attempt));
471                    attempt += 1;
472                }
473            }
474        }
475    }
476
477    /// The resource length: `HEAD`'s `Content-Length`, else a `bytes=0-0`
478    /// GET's `Content-Range` total, else — when that GET is a `200` — the
479    /// whole body's length, with the body kept.
480    fn probe_len(&self) -> std::io::Result<Probe> {
481        // The first read doubles as the length probe: `bytes=0-65535` answers
482        // with the total in `Content-Range` and the bytes every open reads
483        // first anyway. Only when that gives no length is `HEAD` asked.
484        let open_read = self.policy.open_read;
485        let first_what = format!("bytes 0-{} of {}", open_read.saturating_sub(1), self.url);
486        let first_cap = self.policy.whole_body_cap;
487        let first = self.with_retries(|| {
488            if open_read == 0 {
489                return Ok(None);
490            }
491            let resp = match self
492                .request("GET")
493                .set("Range", &format!("bytes=0-{}", open_read - 1))
494                .call()
495            {
496                Ok(r) => r,
497                // A range the server cannot satisfy (an empty file) or refuses
498                // outright: let the HEAD path decide.
499                Err(ureq::Error::Status(code, _)) if !retryable_status(code) => {
500                    return Ok(None);
501                }
502                Err(e) => return Err(classify(e, &first_what)),
503            };
504            match resp.status() {
505                206 => {
506                    // The length comes from here, so the whole header must
507                    // hold together: start 0, the end asked for (or the file's
508                    // last byte), a total. Without a usable one, HEAD decides.
509                    let Some(cr) = resp.header("content-range").and_then(parse_content_range)
510                    else {
511                        return Ok(None);
512                    };
513                    let Some(len) = cr.total.filter(|_| cr.start == 0) else {
514                        return Ok(None);
515                    };
516                    let want = open_read.min(len);
517                    if cr.end + 1 != want {
518                        return Err(Failure::Permanent(std::io::Error::other(format!(
519                            "{first_what}: the server answered Content-Range bytes {}-{}/{len}, \
520                             not the {want} bytes asked for",
521                            cr.start, cr.end
522                        ))));
523                    }
524                    let mut body = Vec::new();
525                    resp.into_reader()
526                        .take(want + 1)
527                        .read_to_end(&mut body)
528                        .map_err(Failure::Transient)?;
529                    if body.len() as u64 > want {
530                        return Err(Failure::Permanent(std::io::Error::other(format!(
531                            "{first_what}: the body is longer than its Content-Range"
532                        ))));
533                    }
534                    if (body.len() as u64) < want {
535                        return Err(Failure::Transient(std::io::Error::other(format!(
536                            "short body for {first_what}"
537                        ))));
538                    }
539                    Ok(Some(Probe {
540                        len,
541                        whole: None,
542                        prefix: Some(body),
543                    }))
544                }
545                200 => match read_capped(resp, first_cap, &first_what)? {
546                    Some(body) if body.is_empty() => Ok(None),
547                    Some(body) => Ok(Some(Probe {
548                        len: body.len() as u64,
549                        whole: Some(body),
550                        prefix: None,
551                    })),
552                    None => Err(Failure::Permanent(whole_body_refusal(
553                        &self.url, None, first_cap,
554                    ))),
555                },
556                _ => Ok(None),
557            }
558        })?;
559        if let Some(p) = first {
560            return Ok(p);
561        }
562        let head_what = format!("HEAD {}", self.url);
563        let head = self.with_retries(|| {
564            self.request("HEAD")
565                .call()
566                .map_err(|e| classify(e, &head_what))
567        });
568        let head_err = match head {
569            Ok(resp) => match resp
570                .header("content-length")
571                .and_then(|v| v.trim().parse::<u64>().ok())
572            {
573                Some(len) => {
574                    return Ok(Probe {
575                        len,
576                        whole: None,
577                        prefix: None,
578                    })
579                }
580                None => "no Content-Length".to_string(),
581            },
582            Err(e) => e.to_string(),
583        };
584        let what = format!("bytes 0-0 of {}", self.url);
585        let cap = self.policy.whole_body_cap;
586        let probe = self.with_retries(|| {
587            let resp = self
588                .request("GET")
589                .set("Range", "bytes=0-0")
590                .call()
591                .map_err(|e| classify(e, &what))?;
592            match resp.status() {
593                206 => Ok(resp
594                    .header("content-range")
595                    .and_then(content_range_total)
596                    .map(|len| Probe {
597                        len,
598                        whole: None,
599                        prefix: None,
600                    })),
601                200 => match read_capped(resp, cap, &what)? {
602                    Some(body) if body.is_empty() => {
603                        Err(Failure::Permanent(std::io::Error::other(format!(
604                            "{} answered 200 with an empty body",
605                            self.url
606                        ))))
607                    }
608                    Some(body) => Ok(Some(Probe {
609                        len: body.len() as u64,
610                        whole: Some(body),
611                        prefix: None,
612                    })),
613                    None => Err(Failure::Permanent(whole_body_refusal(&self.url, None, cap))),
614                },
615                s => Err(Failure::Permanent(std::io::Error::other(format!(
616                    "status {s} for {what}"
617                )))),
618            }
619        })?;
620        probe.ok_or_else(|| {
621            std::io::Error::other(format!(
622                "could not determine the file length of {}: {head_err} on HEAD, and no \
623                 Content-Range total on a ranged GET",
624                self.url
625            ))
626        })
627    }
628
629    /// The bytes of `offset..offset+len` when the opening read holds them.
630    fn prefix_slice(&self, offset: u64, len: u64) -> Option<Vec<u8>> {
631        let end = offset.checked_add(len)?;
632        (end <= self.prefix.len() as u64)
633            .then(|| self.prefix[offset as usize..end as usize].to_vec())
634    }
635
636    /// A range past the length the open measured is an error before any
637    /// request: no retry can make the file longer.
638    fn bounds(&self, offset: u64, len: u64) -> std::io::Result<()> {
639        if offset.checked_add(len).is_none_or(|end| end > self.len) {
640            return Err(std::io::Error::new(
641                std::io::ErrorKind::UnexpectedEof,
642                format!(
643                    "range out of bounds: {offset}+{len} past the {} bytes {} reported",
644                    self.len, self.url
645                ),
646            ));
647        }
648        Ok(())
649    }
650
651    /// A `200` answered a range request: keep its body as the whole file when
652    /// it is one (see the module docs). Concurrent `200`s of one batch keep
653    /// one body — the first to take the lock reads it, the rest drop theirs.
654    fn adopt_whole(&self, resp: ureq::Response, what: &str) -> Result<(), Failure> {
655        let _guard = self.adopting.lock().unwrap_or_else(|p| p.into_inner());
656        if self.whole.get().is_some() {
657            return Ok(());
658        }
659        let cap = self.policy.whole_body_cap;
660        let declared = resp
661            .header("content-length")
662            .and_then(|v| v.trim().parse::<u64>().ok());
663        if let Some(n) = declared {
664            if n > cap {
665                return Err(Failure::Permanent(whole_body_refusal(
666                    &self.url,
667                    Some(n),
668                    cap,
669                )));
670            }
671            if n != self.len {
672                return Err(Failure::Permanent(std::io::Error::other(format!(
673                    "the host ignored the Range header and sent {n} bytes of {}, which is not \
674                     the {} bytes it reported for the file",
675                    self.url, self.len
676                ))));
677            }
678        }
679        let body = read_capped(resp, cap, what)?
680            .ok_or_else(|| Failure::Permanent(whole_body_refusal(&self.url, None, cap)))?;
681        if body.len() as u64 != self.len {
682            return Err(Failure::Permanent(std::io::Error::other(format!(
683                "the host ignored the Range header and sent {} bytes of {}, which is not the \
684                 {} bytes it reported for the file",
685                body.len(),
686                self.url,
687                self.len
688            ))));
689        }
690        if self.policy.trace {
691            eprintln!(
692                "[http] {} ignored Range: every read is served from its {}-byte body",
693                self.url,
694                body.len()
695            );
696        }
697        let _ = self.whole.set(body);
698        Ok(())
699    }
700
701    /// One GET for `[offset, offset+len)` (`len > 0`), classified on failure.
702    fn fetch_once(&self, offset: u64, len: u64) -> Result<Vec<u8>, Failure> {
703        let end = offset + len - 1; // HTTP ranges are inclusive
704        let what = format!("bytes {offset}-{end} of {}", self.url);
705        let resp = self
706            .request("GET")
707            .set("Range", &format!("bytes={offset}-{end}"))
708            .call()
709            .map_err(|e| classify(e, &what))?;
710        // A `200 OK` means the host ignored `Range` and is sending the whole
711        // body from offset 0 — never the requested slice. Kept as the file when
712        // it is one and fits the cap; refused by name otherwise.
713        if resp.status() == 200 {
714            self.adopt_whole(resp, &what)?;
715            let whole = self.whole.get().expect("adopted above");
716            return slice_whole(whole, offset, len, &self.url).map_err(Failure::Permanent);
717        }
718        if resp.status() != 206 {
719            return Err(Failure::Permanent(std::io::Error::other(format!(
720                "server answered status {} (expected 206 Partial Content) for {what}; \
721                 the host must support HTTP range requests",
722                resp.status()
723            ))));
724        }
725        // A 206 for some other range (a broken cache or proxy), or of a file
726        // of another length (the object was replaced under the same URL), is
727        // just as wrong: start, end and total must all be the ones asked for.
728        if let Some(v) = resp.header("content-range") {
729            let Some(cr) = parse_content_range(v) else {
730                return Err(Failure::Permanent(std::io::Error::other(format!(
731                    "server answered a malformed Content-Range `{v}` for {what}"
732                ))));
733            };
734            if cr.start != offset || cr.end != end {
735                return Err(Failure::Permanent(std::io::Error::other(format!(
736                    "server answered Content-Range bytes {}-{} for a request of bytes \
737                     {offset}-{end} ({})",
738                    cr.start, cr.end, self.url
739                ))));
740            }
741            if let Some(t) = cr.total.filter(|&t| t != self.len) {
742                return Err(Failure::Permanent(std::io::Error::other(format!(
743                    "{} is now {t} bytes, not the {} it was when opened: the file changed \
744                     under the same URL",
745                    self.url, self.len
746                ))));
747            }
748        }
749        let size = usize::try_from(len).map_err(|_| {
750            Failure::Permanent(std::io::Error::other(format!(
751                "a {len}-byte range is too large for memory"
752            )))
753        })?;
754        let mut buf = Vec::with_capacity(size);
755        // Read one byte past the range: an exact body then ends at EOF, which
756        // is what hands the connection back to the agent's pool for reuse.
757        resp.into_reader()
758            .take(len + 1)
759            .read_to_end(&mut buf)
760            .map_err(|e| Failure::Transient(std::io::Error::other(format!("{e} ({what})"))))?;
761        // A truncated transfer (server closed early, range past EOF, proxy
762        // hiccup) must be a clean error, not a silently short buffer handed to
763        // the format parsers — mirroring `SliceReader`'s out-of-bounds error.
764        if (buf.len() as u64) < len {
765            return Err(Failure::Transient(std::io::Error::other(format!(
766                "short range response: got {} of {len} bytes at offset {offset} from {}",
767                buf.len(),
768                self.url
769            ))));
770        }
771        // A longer body is not the range: refused, never cut to fit.
772        if buf.len() > size {
773            return Err(Failure::Permanent(std::io::Error::other(format!(
774                "overlong range response: more than {len} bytes for {what}"
775            ))));
776        }
777        Ok(buf)
778    }
779
780    /// [`fetch_once`](Self::fetch_once) with the policy's retries; a read
781    /// after the whole body was kept is a slice of it.
782    fn fetch(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
783        if let Some(whole) = self.whole.get() {
784            return slice_whole(whole, offset, len, &self.url);
785        }
786        self.with_retries(|| self.fetch_once(offset, len))
787    }
788
789    /// Fetch `spans` in order, up to `concurrency` at a time.
790    fn fetch_spans(&self, spans: &[Span]) -> std::io::Result<Vec<Vec<u8>>> {
791        let workers = self.policy.concurrency.min(spans.len());
792        if workers <= 1 {
793            return spans.iter().map(|s| self.fetch(s.offset, s.len)).collect();
794        }
795        let next = AtomicUsize::new(0);
796        let failed = AtomicBool::new(false);
797        type Slot = Mutex<Option<std::io::Result<Vec<u8>>>>;
798        let slots: Vec<Slot> = spans.iter().map(|_| Mutex::new(None)).collect();
799        let panicked = std::thread::scope(|scope| {
800            let handles: Vec<_> = (0..workers)
801                .map(|_| {
802                    scope.spawn(|| loop {
803                        if failed.load(Ordering::Relaxed) {
804                            break; // the batch has failed: start nothing new
805                        }
806                        let i = next.fetch_add(1, Ordering::Relaxed);
807                        let Some(s) = spans.get(i) else { break };
808                        let r = self.fetch(s.offset, s.len);
809                        if r.is_err() {
810                            failed.store(true, Ordering::Relaxed);
811                        }
812                        *slots[i].lock().unwrap_or_else(|p| p.into_inner()) = Some(r);
813                    })
814                })
815                .collect();
816            handles
817                .into_iter()
818                .map(|h| h.join().is_err())
819                .fold(false, |a, b| a | b)
820        });
821        if panicked {
822            return Err(std::io::Error::other("read_many worker panicked"));
823        }
824        let mut out = Vec::with_capacity(spans.len());
825        let mut first_err = None;
826        for slot in slots {
827            match slot.into_inner().unwrap_or_else(|p| p.into_inner()) {
828                Some(Ok(b)) => out.push(b),
829                Some(Err(e)) => {
830                    first_err.get_or_insert(e);
831                }
832                None => {} // never started: the batch had already failed
833            }
834        }
835        match first_err {
836            Some(e) => Err(e),
837            None if out.len() == spans.len() => Ok(out),
838            None => Err(std::io::Error::other("read_many left a range unfetched")),
839        }
840    }
841}
842
843impl RangeReader for HttpRangeReader {
844    fn len(&self) -> u64 {
845        self.len
846    }
847
848    fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
849        if len == 0 {
850            return Ok(Vec::new());
851        }
852        self.bounds(offset, len)?;
853        if let Some(b) = self.prefix_slice(offset, len) {
854            return Ok(b);
855        }
856        if self.policy.trace && self.whole.get().is_none() {
857            eprintln!("[http] read {offset}+{len}");
858        }
859        self.fetch(offset, len)
860    }
861
862    /// The planner's probe-vs-scan hint: how many requests
863    /// [`read_many`](Self::read_many) keeps in flight.
864    fn concurrency(&self) -> usize {
865        self.policy.concurrency
866    }
867
868    /// Coalesce the batch, fetch its requests concurrently, slice the ranges
869    /// back out in request order.
870    fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
871        for &(o, l) in ranges {
872            if l > 0 {
873                self.bounds(o, l)?;
874            }
875        }
876        if let Some(whole) = self.whole.get() {
877            return ranges
878                .iter()
879                .map(|&(o, l)| slice_whole(whole, o, l, &self.url))
880                .collect();
881        }
882        if ranges
883            .iter()
884            .any(|&(o, l)| l > 0 && self.prefix_slice(o, l).is_some())
885        {
886            // Serve what the opening read already holds; fetch the rest.
887            let rest: Vec<(u64, u64)> = ranges
888                .iter()
889                .copied()
890                .filter(|&(o, l)| l > 0 && self.prefix_slice(o, l).is_none())
891                .collect();
892            let mut fetched = if rest.is_empty() {
893                Vec::new()
894            } else {
895                self.read_many(&rest)?
896            }
897            .into_iter();
898            return ranges
899                .iter()
900                .map(|&(o, l)| {
901                    if l == 0 {
902                        Ok(Vec::new())
903                    } else if let Some(b) = self.prefix_slice(o, l) {
904                        Ok(b)
905                    } else {
906                        fetched.next().ok_or_else(|| {
907                            std::io::Error::other("read_many left a range unfetched")
908                        })
909                    }
910                })
911                .collect();
912        }
913        let (spans, place) = coalesce(ranges, self.policy.coalesce_gap, self.policy.max_span)?;
914        if self.policy.trace {
915            let bytes: u64 = spans.iter().map(|s| s.len).sum();
916            eprintln!(
917                "[http] batch of {} range(s) -> {} request(s), {bytes} bytes, {} in flight",
918                ranges.len(),
919                spans.len(),
920                self.policy.concurrency.min(spans.len())
921            );
922        }
923        let blobs = self.fetch_spans(&spans)?;
924        Ok(place
925            .into_iter()
926            .zip(ranges)
927            .map(|(p, &(_, len))| match p {
928                None => Vec::new(),
929                Some((si, at)) => {
930                    let at = at as usize;
931                    blobs[si][at..at + len as usize].to_vec()
932                }
933            })
934            .collect())
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941    use std::io::Write;
942    use std::net::TcpListener;
943    use std::sync::atomic::AtomicU64;
944    use std::sync::Arc;
945
946    /// How the throwaway test server treats range requests.
947    #[derive(Clone, Copy, Debug)]
948    enum ServerMode {
949        /// A real range host: `206` with exactly the requested slice.
950        HonorRange,
951        /// A plain static server: ignores `Range`, replies `200` + whole body.
952        IgnoreRange,
953        /// Ignores `Range` and sends the body with no `Content-Length`, ending
954        /// it by closing the connection.
955        IgnoreRangeUnsized,
956        /// A range host whose `HEAD` carries no `Content-Length` (some object
957        /// stores): the length must come from a ranged GET.
958        HeadWithoutLength,
959        /// `HEAD` without a length AND `Range` ignored: the length probe itself
960        /// receives the whole file.
961        HeadWithoutLengthIgnoreRange,
962        /// A flaky host: claims the full range (`206` + Content-Length) but
963        /// closes the connection after sending only half the bytes.
964        TruncateBody,
965        /// A broken host: `500` on every GET.
966        Error500,
967        /// `404` on every GET: permanent, never retried.
968        NotFound,
969        /// `503` for the first `n` GETs, then a real range host.
970        Unavailable(u64),
971        /// A broken cache: `206` with a `Content-Range` one byte off.
972        WrongOffset,
973        /// `206` whose `Content-Range` end is one past the range asked for.
974        WrongEnd,
975        /// `206` whose `Content-Range` total is not the file's length (the
976        /// object was replaced under the same URL).
977        WrongTotal,
978        /// `206` with the right header and one byte too many in the body.
979        Overlong,
980        /// A real range host, except `404` for a request starting at this offset.
981        FailAt(usize),
982        /// A real range host that answers `401` unless the request carries
983        /// `x-token: secret`.
984        RequireHeader,
985    }
986
987    /// What the server saw: GETs served, and the most it had in flight at once.
988    ///
989    /// A GET is in flight from the moment its handler counts it until the
990    /// moment it starts to answer — not until the handler thread finishes. A
991    /// client may send its next request as soon as it has read the answer's
992    /// Content-Length bytes, so counting until after the write measured the
993    /// handler's tail: a strictly sequential client was sometimes counted
994    /// twice (2 for `concurrency_one_is_sequential` in CI on #362; 5 for 4 in
995    /// `read_many_overlaps_requests_up_to_the_bound` under load).
996    #[derive(Default)]
997    struct Stats {
998        gets: AtomicU64,
999        in_flight: AtomicU64,
1000        max_in_flight: AtomicU64,
1001    }
1002
1003    /// A throwaway localhost HTTP/1.1 server over the given bytes, with the
1004    /// given range behavior; one thread per connection, each GET held for
1005    /// `delay` so concurrent requests overlap. Returns the base URL and the
1006    /// server's counters; the server thread is detached.
1007    fn serve_with(data: Vec<u8>, mode: ServerMode, delay: Duration) -> (String, Arc<Stats>) {
1008        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1009        let url = format!("http://{}/", listener.local_addr().unwrap());
1010        let stats = Arc::new(Stats::default());
1011        let data = Arc::new(data);
1012        let st = stats.clone();
1013        std::thread::spawn(move || {
1014            for stream in listener.incoming() {
1015                let Ok(stream) = stream else { continue };
1016                let (data, st) = (data.clone(), st.clone());
1017                std::thread::spawn(move || handle(stream, &data, mode, delay, &st));
1018            }
1019        });
1020        (url, stats)
1021    }
1022
1023    fn serve(data: Vec<u8>, mode: ServerMode) -> String {
1024        serve_with(data, mode, Duration::ZERO).0
1025    }
1026
1027    fn handle(
1028        mut stream: std::net::TcpStream,
1029        data: &[u8],
1030        mode: ServerMode,
1031        delay: Duration,
1032        st: &Stats,
1033    ) {
1034        // Read the request head (until CRLFCRLF).
1035        let mut req = Vec::new();
1036        let mut byte = [0u8; 1];
1037        while !req.ends_with(b"\r\n\r\n") {
1038            use std::io::Read as _;
1039            if stream.read(&mut byte).unwrap_or(0) == 0 {
1040                break;
1041            }
1042            req.push(byte[0]);
1043        }
1044        let text = String::from_utf8_lossy(&req);
1045        let is_head = text.starts_with("HEAD");
1046        let range = text.lines().find_map(|l| {
1047            let l = l.to_ascii_lowercase();
1048            l.strip_prefix("range: bytes=")
1049                .map(|r| r.trim().to_string())
1050        });
1051        let authorized = text
1052            .lines()
1053            .any(|l| l.eq_ignore_ascii_case("x-token: secret"));
1054
1055        let total = data.len();
1056        let status_only = |stream: &mut std::net::TcpStream, line: &str| {
1057            let _ = stream.write_all(
1058                format!("HTTP/1.1 {line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
1059                    .as_bytes(),
1060            );
1061        };
1062        if matches!(mode, ServerMode::RequireHeader) && !authorized {
1063            status_only(&mut stream, "401 Unauthorized");
1064            return;
1065        }
1066        if is_head {
1067            let h = if matches!(
1068                mode,
1069                ServerMode::HeadWithoutLength | ServerMode::HeadWithoutLengthIgnoreRange
1070            ) {
1071                "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".to_string()
1072            } else {
1073                format!(
1074                    "HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n"
1075                )
1076            };
1077            let _ = stream.write_all(h.as_bytes());
1078            return;
1079        }
1080        let n = st.gets.fetch_add(1, Ordering::SeqCst) + 1;
1081        let now = st.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1082        st.max_in_flight.fetch_max(now, Ordering::SeqCst);
1083        std::thread::sleep(delay);
1084        // Out of flight BEFORE the first byte of the answer: the client cannot
1085        // send its next request until it has read this one, so the decrement
1086        // happens before that request is counted (see `Stats`).
1087        st.in_flight.fetch_sub(1, Ordering::SeqCst);
1088        let ignores_range = matches!(
1089            mode,
1090            ServerMode::IgnoreRange
1091                | ServerMode::IgnoreRangeUnsized
1092                | ServerMode::HeadWithoutLengthIgnoreRange
1093        );
1094        match (mode, range) {
1095            (ServerMode::Error500, _) => status_only(&mut stream, "500 Internal Server Error"),
1096            (ServerMode::NotFound, _) => status_only(&mut stream, "404 Not Found"),
1097            (ServerMode::Unavailable(k), _) if n <= k => {
1098                status_only(&mut stream, "503 Service Unavailable")
1099            }
1100            (ServerMode::FailAt(at), Some(r)) if r.starts_with(&format!("{at}-")) => {
1101                status_only(&mut stream, "404 Not Found")
1102            }
1103            (ServerMode::IgnoreRangeUnsized, _) => {
1104                let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n");
1105                let _ = stream.write_all(data);
1106            }
1107            (_, Some(r)) if !ignores_range => {
1108                let (a, b) = r.split_once('-').unwrap();
1109                let a: usize = a.parse().unwrap();
1110                let b: usize = b.parse().unwrap();
1111                let slice = &data[a.min(total)..=b.min(total - 1)];
1112                let claimed = if matches!(mode, ServerMode::WrongOffset) {
1113                    a + 1
1114                } else {
1115                    a
1116                };
1117                // A real host answers with the last byte it has (RFC 9110
1118                // §14.1.2: a range past the end is cut at the end).
1119                let last = b.min(total - 1);
1120                let claimed_end = if matches!(mode, ServerMode::WrongEnd) {
1121                    last + 1
1122                } else {
1123                    last
1124                };
1125                let claimed_total = if matches!(mode, ServerMode::WrongTotal) {
1126                    total + 10
1127                } else {
1128                    total
1129                };
1130                let extra = usize::from(matches!(mode, ServerMode::Overlong));
1131                let h = format!(
1132                    "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {claimed}-{claimed_end}/{claimed_total}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1133                    slice.len() + extra
1134                );
1135                let _ = stream.write_all(h.as_bytes());
1136                let sent = if matches!(mode, ServerMode::TruncateBody) {
1137                    &slice[..slice.len() / 2] // lie, then hang up early
1138                } else {
1139                    slice
1140                };
1141                let _ = stream.write_all(sent);
1142                if extra > 0 {
1143                    let _ = stream.write_all(b"!");
1144                }
1145            }
1146            // Ignore Range (or no Range sent): 200 with the whole body.
1147            _ => {
1148                let h = format!(
1149                    "HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n"
1150                );
1151                let _ = stream.write_all(h.as_bytes());
1152                let _ = stream.write_all(data);
1153            }
1154        }
1155    }
1156
1157    /// The default policy with quick retries, so failure tests stay fast.
1158    /// The read-path tests probe with `HEAD` (no opening read), so every
1159    /// range they ask for reaches the test server.
1160    fn quick() -> FetchPolicy {
1161        FetchPolicy {
1162            backoff_ms: 1,
1163            open_read: 0,
1164            ..FetchPolicy::DEFAULT
1165        }
1166    }
1167
1168    /// The default open: one `bytes=0-65535` GET is both the length probe and
1169    /// the first read; no `HEAD`, and later reads inside it never leave memory.
1170    #[test]
1171    fn the_open_reads_first_and_sends_no_head() {
1172        let data: Vec<u8> = (0..=255u8).cycle().take(100_000).collect();
1173        let (url, stats) = serve_with(data.clone(), ServerMode::HeadWithoutLength, Duration::ZERO);
1174        let policy = FetchPolicy {
1175            backoff_ms: 1,
1176            whole_max: 0,
1177            ..FetchPolicy::DEFAULT
1178        };
1179        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1180        assert_eq!(r.len(), 100_000);
1181        assert_eq!(stats.gets.load(Ordering::SeqCst), 1, "the open is one GET");
1182        assert_eq!(r.read_at(1000, 24).unwrap(), &data[1000..1024]);
1183        assert_eq!(
1184            r.read_many(&[(0, 16), (60_000, 8)]).unwrap()[1],
1185            &data[60_000..60_008]
1186        );
1187        assert_eq!(
1188            stats.gets.load(Ordering::SeqCst),
1189            1,
1190            "served from the first read"
1191        );
1192        assert_eq!(r.read_at(70_000, 4).unwrap(), &data[70_000..70_004]);
1193        assert_eq!(
1194            stats.gets.load(Ordering::SeqCst),
1195            2,
1196            "past it: one more GET"
1197        );
1198        // A file shorter than the first read is read whole by the open.
1199        let small: Vec<u8> = (0..100u8).collect();
1200        let (url, stats) = serve_with(small.clone(), ServerMode::HonorRange, Duration::ZERO);
1201        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1202        assert_eq!(r.len(), 100);
1203        assert_eq!(r.read_at(90, 10).unwrap(), &small[90..]);
1204        assert_eq!(stats.gets.load(Ordering::SeqCst), 1);
1205    }
1206
1207    #[test]
1208    fn reads_exact_ranges_from_a_range_host() {
1209        let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
1210        let url = serve(data.clone(), ServerMode::HonorRange);
1211        let r = HttpRangeReader::open(&url).unwrap();
1212        assert_eq!(r.len(), 1000);
1213        assert_eq!(r.read_at(0, 4).unwrap(), &data[0..4]);
1214        assert_eq!(r.read_at(500, 20).unwrap(), &data[500..520]);
1215        assert_eq!(r.read_at(996, 4).unwrap(), &data[996..1000]);
1216        assert!(r.read_at(0, 0).unwrap().is_empty());
1217        assert!(!r.serves_whole_body());
1218    }
1219
1220    /// Proves the `https://` transport works end-to-end against a real host that
1221    /// honors Range. Network-dependent, so `#[ignore]`d — CI never touches the
1222    /// network. Run on demand: `cargo test -p rete-core --features http -- --ignored https`.
1223    #[test]
1224    #[ignore = "hits the network (httpbin); run with --ignored"]
1225    fn reads_ranges_over_https() {
1226        let url = "https://httpbin.org/range/2048";
1227        let r = HttpRangeReader::open(url).unwrap();
1228        assert!(r.len() >= 2048);
1229        let chunk = r.read_at(100, 16).unwrap();
1230        assert_eq!(chunk.len(), 16);
1231    }
1232
1233    /// G6: a host that ignores `Range` answers `200` with the whole body. It is
1234    /// never mis-read as the requested slice: under the cap it is kept, and
1235    /// every read after it — single or batched — is a slice of it, with no
1236    /// further request.
1237    #[test]
1238    fn keeps_the_whole_body_of_a_host_that_ignores_range() {
1239        let data: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
1240        let (url, stats) = serve_with(data.clone(), ServerMode::IgnoreRange, Duration::ZERO);
1241        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1242        assert_eq!(r.len(), 5000);
1243        assert_eq!(r.read_at(50, 10).unwrap(), &data[50..60]);
1244        assert!(r.serves_whole_body());
1245        assert_eq!(r.read_at(4990, 10).unwrap(), &data[4990..5000]);
1246        let got = r.read_many(&[(0, 4), (3000, 100), (7, 0)]).unwrap();
1247        assert_eq!(got[0], &data[0..4]);
1248        assert_eq!(got[1], &data[3000..3100]);
1249        assert!(got[2].is_empty());
1250        assert_eq!(
1251            stats.gets.load(Ordering::SeqCst),
1252            1,
1253            "one download, then memory"
1254        );
1255        // Bounds still hold against the length the open measured.
1256        assert!(r.read_at(4999, 2).is_err());
1257    }
1258
1259    /// The same for a body without `Content-Length` (ended by closing the
1260    /// connection): read, measured, kept.
1261    #[test]
1262    fn keeps_an_unsized_whole_body_too() {
1263        let data: Vec<u8> = (0..=255u8).cycle().take(3000).collect();
1264        let (url, stats) = serve_with(data.clone(), ServerMode::IgnoreRangeUnsized, Duration::ZERO);
1265        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1266        assert_eq!(
1267            r.read_many(&[(10, 5), (2000, 5)]).unwrap()[1],
1268            &data[2000..2005]
1269        );
1270        assert_eq!(stats.gets.load(Ordering::SeqCst), 1);
1271    }
1272
1273    /// Above the cap the body is refused by name — permanently, so it is not
1274    /// downloaded again by a retry — and with a declared length, unread.
1275    #[test]
1276    fn refuses_a_whole_body_over_the_cap() {
1277        let small_cap = FetchPolicy {
1278            whole_body_cap: 1000,
1279            ..quick()
1280        };
1281        let data: Vec<u8> = (0..=255u8).cycle().take(4000).collect();
1282        for mode in [ServerMode::IgnoreRange, ServerMode::IgnoreRangeUnsized] {
1283            let (url, stats) = serve_with(data.clone(), mode, Duration::ZERO);
1284            let r = HttpRangeReader::open_with(&url, small_cap).unwrap();
1285            let err = r.read_at(50, 10).unwrap_err().to_string();
1286            assert!(
1287                err.contains("ignored the Range header") && err.contains("not kept in memory"),
1288                "{err}"
1289            );
1290            assert_eq!(stats.gets.load(Ordering::SeqCst), 1, "permanent: no retry");
1291            assert!(!r.serves_whole_body());
1292            // A batch is refused the same way.
1293            assert!(r.read_many(&[(0, 1), (2000, 1)]).is_err());
1294        }
1295        // The real cap is the browser readers'.
1296        assert_eq!(FetchPolicy::DEFAULT.whole_body_cap, 256 << 20);
1297        let msg = whole_body_refusal("u", Some(300 << 20), WHOLE_BODY_CAP).to_string();
1298        assert!(
1299            msg.contains("256 MiB") && msg.contains("status 200"),
1300            "{msg}"
1301        );
1302    }
1303
1304    /// A `HEAD` without a length is answered by a ranged GET's
1305    /// `Content-Range`; and when that GET is itself a `200`, the open keeps the
1306    /// file it was sent and no read ever goes out again.
1307    #[test]
1308    fn measures_the_length_without_a_head_content_length() {
1309        let data: Vec<u8> = (0..=255u8).cycle().take(2048).collect();
1310        let (url, stats) = serve_with(data.clone(), ServerMode::HeadWithoutLength, Duration::ZERO);
1311        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1312        assert_eq!(r.len(), 2048);
1313        assert_eq!(r.read_at(100, 8).unwrap(), &data[100..108]);
1314        assert!(!r.serves_whole_body());
1315        assert_eq!(stats.gets.load(Ordering::SeqCst), 2, "probe + read");
1316
1317        let (url, stats) = serve_with(
1318            data.clone(),
1319            ServerMode::HeadWithoutLengthIgnoreRange,
1320            Duration::ZERO,
1321        );
1322        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1323        assert_eq!(r.len(), 2048);
1324        assert!(r.serves_whole_body());
1325        assert_eq!(r.read_at(2000, 48).unwrap(), &data[2000..2048]);
1326        assert_eq!(
1327            stats.gets.load(Ordering::SeqCst),
1328            1,
1329            "the probe was the file"
1330        );
1331    }
1332
1333    /// Extra headers ride on every request, the length probe included.
1334    #[test]
1335    fn sends_the_extra_headers_on_every_request() {
1336        let data: Vec<u8> = (0..100u8).collect();
1337        let url = serve(data.clone(), ServerMode::RequireHeader);
1338        assert!(HttpRangeReader::open_with(&url, quick()).is_err());
1339        let headers = vec![("x-token".to_string(), "secret".to_string())];
1340        let r = HttpRangeReader::open_with_headers(&url, headers, quick()).unwrap();
1341        assert_eq!(r.len(), 100);
1342        assert_eq!(r.read_many(&[(0, 2), (90, 10)]).unwrap()[1], &data[90..100]);
1343    }
1344
1345    #[test]
1346    fn rejects_a_truncated_range_response() {
1347        // A 206 that claims the slice but delivers only part of it (dropped
1348        // connection, range past EOF, broken proxy) must surface as a clean
1349        // error — never as a silently short buffer.
1350        let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
1351        let (url, stats) = serve_with(data, ServerMode::TruncateBody, Duration::ZERO);
1352        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1353        // ureq itself flags the Content-Length mismatch ("body closed before
1354        // all bytes were read"); our own check is the backstop for transports
1355        // that don't. Either way: an error, never a short buffer.
1356        let err = r.read_at(100, 40).unwrap_err();
1357        let msg = err.to_string();
1358        assert!(
1359            msg.contains("short range response") || msg.contains("closed before"),
1360            "expected a truncation error, got: {err}"
1361        );
1362        assert_eq!(
1363            stats.gets.load(Ordering::SeqCst),
1364            3,
1365            "a truncated body is transient: retried twice"
1366        );
1367        // And a range that runs past EOF errors too, before any request.
1368        let data: Vec<u8> = (0..100u8).collect();
1369        let (url, stats) = serve_with(data, ServerMode::HonorRange, Duration::ZERO);
1370        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1371        let err = r.read_at(90, 20).unwrap_err();
1372        assert!(err.to_string().contains("out of bounds"), "{err}");
1373        assert!(r.read_many(&[(0, 1), (90, 20)]).is_err());
1374        assert_eq!(stats.gets.load(Ordering::SeqCst), 0);
1375    }
1376
1377    #[test]
1378    fn server_errors_are_clean_errors() {
1379        let data: Vec<u8> = (0..100u8).collect();
1380        let (url, stats) = serve_with(data, ServerMode::Error500, Duration::ZERO);
1381        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1382        // HEAD succeeded; the GET's 500 must come back as an error, not bytes.
1383        let err = r.read_at(0, 10).unwrap_err();
1384        assert!(err.to_string().contains("HTTP 500"), "{err}");
1385        assert!(err.to_string().contains("after 3 attempts"), "{err}");
1386        assert_eq!(stats.gets.load(Ordering::SeqCst), 3);
1387    }
1388
1389    #[test]
1390    fn a_missing_file_is_not_retried() {
1391        let (url, stats) = serve_with(vec![0; 100], ServerMode::NotFound, Duration::ZERO);
1392        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1393        let err = r.read_at(0, 10).unwrap_err();
1394        assert!(err.to_string().contains("HTTP 404"), "{err}");
1395        assert_eq!(stats.gets.load(Ordering::SeqCst), 1);
1396    }
1397
1398    #[test]
1399    fn a_transient_failure_is_retried_and_recovers() {
1400        let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
1401        let (url, stats) = serve_with(data.clone(), ServerMode::Unavailable(2), Duration::ZERO);
1402        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1403        assert_eq!(r.read_at(10, 30).unwrap(), &data[10..40]);
1404        assert_eq!(stats.gets.load(Ordering::SeqCst), 3, "two 503s, then 206");
1405        // With retries off the same outage is an error.
1406        let (url, _) = serve_with(data, ServerMode::Unavailable(1), Duration::ZERO);
1407        let none = FetchPolicy {
1408            retries: 0,
1409            ..quick()
1410        };
1411        let r = HttpRangeReader::open_with(&url, none).unwrap();
1412        assert!(r.read_at(10, 30).unwrap_err().to_string().contains("503"));
1413    }
1414
1415    #[test]
1416    fn rejects_a_range_answered_for_the_wrong_offset() {
1417        let (url, _) = serve_with(vec![7; 1000], ServerMode::WrongOffset, Duration::ZERO);
1418        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1419        let err = r.read_at(100, 10).unwrap_err();
1420        assert!(
1421            err.to_string().contains("Content-Range bytes 101-109"),
1422            "{err}"
1423        );
1424    }
1425
1426    /// Strict `Content-Range` (audit fix 2): the end and the total must be the
1427    /// ones asked for, and a body one byte too long is refused rather than
1428    /// cut to fit.
1429    #[test]
1430    fn rejects_a_wrong_end_a_wrong_total_and_an_overlong_body() {
1431        for (mode, want) in [
1432            (ServerMode::WrongEnd, "Content-Range bytes 100-110"),
1433            (ServerMode::WrongTotal, "changed under the same URL"),
1434            (ServerMode::Overlong, "overlong range response"),
1435        ] {
1436            let (url, _) = serve_with(vec![7; 1000], mode, Duration::ZERO);
1437            let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1438            let err = r.read_at(100, 10).unwrap_err();
1439            assert!(err.to_string().contains(want), "{mode:?}: {err}");
1440        }
1441        // The opening read is held to the same rule.
1442        let (url, _) = serve_with(vec![7; 100_000], ServerMode::WrongEnd, Duration::ZERO);
1443        let policy = FetchPolicy {
1444            backoff_ms: 1,
1445            ..FetchPolicy::DEFAULT
1446        };
1447        let err = HttpRangeReader::open_with(&url, policy)
1448            .err()
1449            .expect("refused");
1450        assert!(
1451            err.to_string().contains("not the 65536 bytes asked for"),
1452            "{err}"
1453        );
1454        let (url, _) = serve_with(vec![7; 100_000], ServerMode::Overlong, Duration::ZERO);
1455        let err = HttpRangeReader::open_with(&url, policy)
1456            .err()
1457            .expect("refused");
1458        assert!(
1459            err.to_string().contains("longer than its Content-Range"),
1460            "{err}"
1461        );
1462    }
1463
1464    /// A file at most `whole_max` long is fetched whole by the open: the first
1465    /// read, then the rest in one more, then nothing.
1466    #[test]
1467    fn a_small_file_is_fetched_whole_at_open() {
1468        let data: Vec<u8> = (0..=255u8).cycle().take(300_000).collect();
1469        let (url, stats) = serve_with(data.clone(), ServerMode::HonorRange, Duration::ZERO);
1470        let policy = FetchPolicy {
1471            backoff_ms: 1,
1472            ..FetchPolicy::DEFAULT
1473        };
1474        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1475        assert_eq!(
1476            stats.gets.load(Ordering::SeqCst),
1477            2,
1478            "first read + the rest"
1479        );
1480        assert_eq!(r.read_at(299_990, 10).unwrap(), &data[299_990..]);
1481        assert_eq!(
1482            r.read_many(&[(0, 4), (200_000, 8)]).unwrap()[1],
1483            &data[200_000..200_008]
1484        );
1485        assert_eq!(
1486            stats.gets.load(Ordering::SeqCst),
1487            2,
1488            "nothing after the open"
1489        );
1490        // Past the knob, reads stay ranged.
1491        let (url, stats) = serve_with(data.clone(), ServerMode::HonorRange, Duration::ZERO);
1492        let small = FetchPolicy {
1493            whole_max: 100_000,
1494            ..policy
1495        };
1496        let r = HttpRangeReader::open_with(&url, small).unwrap();
1497        assert_eq!(stats.gets.load(Ordering::SeqCst), 1);
1498        assert_eq!(r.read_at(200_000, 8).unwrap(), &data[200_000..200_008]);
1499        assert_eq!(stats.gets.load(Ordering::SeqCst), 2);
1500    }
1501
1502    /// The engine's batch goes out concurrently, bounded by the policy, and
1503    /// comes back in request order with exact bytes.
1504    #[test]
1505    fn read_many_overlaps_requests_up_to_the_bound() {
1506        let data: Vec<u8> = (0..=255u8).cycle().take(1 << 20).collect();
1507        let (url, stats) = serve_with(
1508            data.clone(),
1509            ServerMode::HonorRange,
1510            Duration::from_millis(40),
1511        );
1512        let policy = FetchPolicy {
1513            concurrency: 4,
1514            coalesce_gap: 0,
1515            ..quick()
1516        };
1517        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1518        // 12 ranges 64 KiB apart, given out of order, plus an empty one.
1519        let mut ranges: Vec<(u64, u64)> = (0..12).map(|i| (i * 65_536 + 7, 100)).collect();
1520        ranges.reverse();
1521        ranges.insert(3, (5, 0));
1522        let t = std::time::Instant::now();
1523        let got = r.read_many(&ranges).unwrap();
1524        let took = t.elapsed();
1525        assert_eq!(got.len(), ranges.len());
1526        for (&(o, l), b) in ranges.iter().zip(&got) {
1527            assert_eq!(b.as_slice(), &data[o as usize..(o + l) as usize]);
1528        }
1529        assert_eq!(stats.gets.load(Ordering::SeqCst), 12);
1530        assert_eq!(
1531            stats.max_in_flight.load(Ordering::SeqCst),
1532            4,
1533            "bounded fan-out"
1534        );
1535        // 12 requests, 4 at a time, 40 ms each: 3 waves, not 12.
1536        assert!(took < Duration::from_millis(12 * 40), "{took:?}");
1537    }
1538
1539    /// `RETE_HTTP_CONCURRENCY=1` behaves exactly like the old sequential reader.
1540    #[test]
1541    fn concurrency_one_is_sequential() {
1542        let data: Vec<u8> = (0..=255u8).cycle().take(4096).collect();
1543        let (url, stats) = serve_with(data, ServerMode::HonorRange, Duration::from_millis(5));
1544        let policy = FetchPolicy {
1545            concurrency: 1,
1546            coalesce_gap: 0,
1547            ..quick()
1548        };
1549        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1550        assert_eq!(r.concurrency(), 1);
1551        r.read_many(&[(0, 10), (1000, 10), (3000, 10)]).unwrap();
1552        assert_eq!(stats.max_in_flight.load(Ordering::SeqCst), 1);
1553        assert_eq!(stats.gets.load(Ordering::SeqCst), 3);
1554    }
1555
1556    /// Nearby ranges share one request; far ones do not.
1557    #[test]
1558    fn read_many_coalesces_nearby_ranges() {
1559        let data: Vec<u8> = (0..=255u8).cycle().take(1 << 20).collect();
1560        let (url, stats) = serve_with(data.clone(), ServerMode::HonorRange, Duration::ZERO);
1561        let policy = FetchPolicy {
1562            coalesce_gap: 1024,
1563            ..quick()
1564        };
1565        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1566        let ranges = [(5000, 100), (100, 50), (200, 10), (190, 30), (6000, 1)];
1567        let got = r.read_many(&ranges).unwrap();
1568        for (&(o, l), b) in ranges.iter().zip(&got) {
1569            assert_eq!(b.as_slice(), &data[o as usize..(o + l) as usize]);
1570        }
1571        // [100..220) is one request (overlapping and close); [5000..6001) another.
1572        assert_eq!(stats.gets.load(Ordering::SeqCst), 2);
1573    }
1574
1575    /// One failing range fails the whole batch, and the error names it.
1576    #[test]
1577    fn read_many_fails_the_batch_on_one_bad_range() {
1578        let data: Vec<u8> = (0..100u8).collect();
1579        let url = serve(data, ServerMode::FailAt(50));
1580        let r = HttpRangeReader::open_with(
1581            &url,
1582            FetchPolicy {
1583                coalesce_gap: 0,
1584                ..quick()
1585            },
1586        )
1587        .unwrap();
1588        let err = r.read_many(&[(0, 10), (50, 10), (20, 10)]).unwrap_err();
1589        assert!(
1590            err.to_string().contains("HTTP 404 for bytes 50-59"),
1591            "{err}"
1592        );
1593        assert!(r.read_many(&[(0, 10), (20, 10)]).is_ok());
1594    }
1595
1596    #[test]
1597    fn coalesce_plans_spans_and_placements() {
1598        let (spans, place) = coalesce(&[(10, 5), (0, 0), (18, 4), (100, 1)], 3, 1 << 20).unwrap();
1599        assert_eq!(
1600            spans,
1601            vec![
1602                Span {
1603                    offset: 10,
1604                    len: 12
1605                },
1606                Span {
1607                    offset: 100,
1608                    len: 1
1609                }
1610            ]
1611        );
1612        assert_eq!(place, vec![Some((0, 0)), None, Some((0, 8)), Some((1, 0))]);
1613        // The span cap keeps a merge from growing without bound.
1614        let (spans, _) = coalesce(&[(0, 10), (10, 10), (20, 10)], 0, 20).unwrap();
1615        assert_eq!(spans.len(), 2);
1616        assert!(coalesce(&[(u64::MAX, 2)], 0, 10).is_err());
1617    }
1618
1619    #[test]
1620    fn parses_content_range() {
1621        let cr = |s, e, t| {
1622            Some(ContentRange {
1623                start: s,
1624                end: e,
1625                total: t,
1626            })
1627        };
1628        assert_eq!(
1629            parse_content_range("bytes 100-199/1000"),
1630            cr(100, 199, Some(1000))
1631        );
1632        assert_eq!(parse_content_range(" bytes 0-0/*"), cr(0, 0, None));
1633        assert_eq!(content_range_total("bytes 0-0/1000"), Some(1000));
1634        assert_eq!(content_range_total("bytes 0-0/*"), None);
1635        for bad in [
1636            "items 1-2/3",
1637            "bytes 5-4/10",
1638            "bytes 0-10/10",
1639            "bytes -1-4/10",
1640            "bytes 1-+4/10",
1641            "bytes 0-4",
1642            "bytes 0-4/x",
1643            "bytes */10",
1644        ] {
1645            assert_eq!(parse_content_range(bad), None, "{bad}");
1646        }
1647    }
1648
1649    #[test]
1650    fn retryable_statuses() {
1651        for c in [408, 429, 500, 502, 503, 504] {
1652            assert!(retryable_status(c), "{c}");
1653        }
1654        for c in [400, 403, 404, 416, 501, 505] {
1655            assert!(!retryable_status(c), "{c}");
1656        }
1657    }
1658}