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}
81
82impl FetchPolicy {
83    /// 16 in flight matches the wasm client's fetch-worker pool and the Node
84    /// client's `reteReadMany` cap; a CDN or S3 serves that many concurrent
85    /// range reads happily.
86    pub const DEFAULT: FetchPolicy = FetchPolicy {
87        concurrency: 16,
88        coalesce_gap: 32 << 10,
89        max_span: 8 << 20,
90        retries: 2,
91        backoff_ms: 100,
92        trace: false,
93        whole_body_cap: WHOLE_BODY_CAP,
94    };
95
96    /// [`FetchPolicy::DEFAULT`] with the `RETE_HTTP_*` overrides applied.
97    pub fn from_env() -> Self {
98        let num = |k: &str| {
99            std::env::var(k)
100                .ok()
101                .and_then(|v| v.trim().parse::<u64>().ok())
102        };
103        let d = Self::DEFAULT;
104        FetchPolicy {
105            concurrency: num("RETE_HTTP_CONCURRENCY")
106                .map_or(d.concurrency, |n| n.clamp(1, 64) as usize),
107            coalesce_gap: num("RETE_HTTP_COALESCE_KB")
108                .map_or(d.coalesce_gap, |kb| kb.saturating_mul(1024)),
109            max_span: d.max_span,
110            retries: num("RETE_HTTP_RETRIES").map_or(d.retries, |n| n.min(10) as u32),
111            backoff_ms: d.backoff_ms,
112            trace: std::env::var_os("RETE_HTTP_TRACE").is_some(),
113            whole_body_cap: d.whole_body_cap,
114        }
115    }
116
117    fn backoff(&self, attempt: u32) -> Duration {
118        let ms = self
119            .backoff_ms
120            .saturating_mul(4u64.saturating_pow(attempt))
121            .min(2000);
122        Duration::from_millis(ms)
123    }
124}
125
126impl Default for FetchPolicy {
127    fn default() -> Self {
128        Self::DEFAULT
129    }
130}
131
132/// Why one request failed: worth another attempt, or not.
133enum Failure {
134    Transient(std::io::Error),
135    Permanent(std::io::Error),
136}
137
138/// `408 Request Timeout`, `429 Too Many Requests` and the 5xx a retry can cure
139/// (not `501 Not Implemented` or `505 HTTP Version Not Supported`).
140fn retryable_status(code: u16) -> bool {
141    code == 408 || code == 429 || ((500..600).contains(&code) && code != 501 && code != 505)
142}
143
144fn classify(err: ureq::Error, what: &str) -> Failure {
145    match err {
146        ureq::Error::Status(code, _) => {
147            let e = std::io::Error::other(format!("HTTP {code} for {what}"));
148            if retryable_status(code) {
149                Failure::Transient(e)
150            } else {
151                Failure::Permanent(e)
152            }
153        }
154        ureq::Error::Transport(t) => {
155            let transient = matches!(
156                t.kind(),
157                ureq::ErrorKind::Io
158                    | ureq::ErrorKind::ConnectionFailed
159                    | ureq::ErrorKind::Dns
160                    | ureq::ErrorKind::ProxyConnect
161            );
162            let e = std::io::Error::other(format!("{t} ({what})"));
163            if transient {
164                Failure::Transient(e)
165            } else {
166                Failure::Permanent(e)
167            }
168        }
169    }
170}
171
172/// The first byte of a `Content-Range: bytes a-b/N` value.
173fn content_range_start(v: &str) -> Option<u64> {
174    let v = v.trim().strip_prefix("bytes")?.trim_start();
175    v.split(['-', '/']).next()?.trim().parse().ok()
176}
177
178/// The total of a `Content-Range: bytes a-b/N` value (`None` for `/*`).
179fn content_range_total(v: &str) -> Option<u64> {
180    v.rsplit('/').next()?.trim().parse().ok()
181}
182
183/// One request of a coalesced batch.
184#[derive(Debug, PartialEq, Eq)]
185struct Span {
186    offset: u64,
187    len: u64,
188}
189
190/// Where each requested range of a batch lands: `(request, offset within
191/// that request)`, or `None` for an empty range.
192type Placement = Vec<Option<(usize, u64)>>;
193
194/// Merge a batch's non-empty ranges into the requests that fetch them: sorted
195/// by offset, a range joins the current request when it starts at most `gap`
196/// bytes past its end and the request stays within `max_span`. Returns the
197/// requests and, per input range, where its bytes are.
198fn coalesce(
199    ranges: &[(u64, u64)],
200    gap: u64,
201    max_span: u64,
202) -> std::io::Result<(Vec<Span>, Placement)> {
203    let mut order: Vec<usize> = (0..ranges.len()).filter(|&i| ranges[i].1 > 0).collect();
204    for &i in &order {
205        let (o, l) = ranges[i];
206        if o.checked_add(l).is_none() {
207            return Err(std::io::Error::new(
208                std::io::ErrorKind::InvalidInput,
209                format!("range {o}+{l} overflows"),
210            ));
211        }
212    }
213    order.sort_by_key(|&i| ranges[i]);
214    let mut spans: Vec<Span> = Vec::new();
215    let mut place: Placement = vec![None; ranges.len()];
216    for i in order {
217        let (o, l) = ranges[i];
218        let end = o + l;
219        let joins = spans.last().is_some_and(|s| {
220            let s_end = s.offset + s.len;
221            o <= s_end.saturating_add(gap) && end.max(s_end) - s.offset <= max_span
222        });
223        if joins {
224            let s = spans.last_mut().expect("checked above");
225            s.len = (s.offset + s.len).max(end) - s.offset;
226        } else {
227            spans.push(Span { offset: o, len: l });
228        }
229        let si = spans.len() - 1;
230        place[i] = Some((si, o - spans[si].offset));
231    }
232    Ok((spans, place))
233}
234
235/// The refusal for a whole body over the cap: named, so it reads as the host's
236/// limitation rather than as a broken file.
237fn whole_body_refusal(url: &str, bytes: Option<u64>, cap: u64) -> std::io::Error {
238    let size = match bytes {
239        Some(n) => format!("all {n} bytes"),
240        None => format!("more than {cap} bytes"),
241    };
242    std::io::Error::other(format!(
243        "the host ignored the Range header and sent {size} of {url} (status 200 instead \
244         of 206 Partial Content); a full body over {} MiB is not kept in memory — host the \
245         file where HTTP range requests work",
246        cap >> 20
247    ))
248}
249
250/// One read served from a whole-resource body, bounds-checked: the body is
251/// whatever the host sent, so a read past its end is a clean error.
252fn slice_whole(whole: &[u8], offset: u64, len: u64, url: &str) -> std::io::Result<Vec<u8>> {
253    let end = offset
254        .checked_add(len)
255        .filter(|&end| end <= whole.len() as u64)
256        .ok_or_else(|| {
257            std::io::Error::other(format!(
258                "range {offset}+{len} is past the end of the {} bytes {url} sent",
259                whole.len()
260            ))
261        })?;
262    Ok(whole[offset as usize..end as usize].to_vec())
263}
264
265/// Read a `200` body whole, up to `cap` bytes: `Ok(None)` when it is longer
266/// (the caller refuses it), never more than `cap + 1` bytes in memory.
267fn read_capped(resp: ureq::Response, cap: u64, what: &str) -> Result<Option<Vec<u8>>, Failure> {
268    let declared = resp
269        .header("content-length")
270        .and_then(|v| v.trim().parse::<u64>().ok());
271    if declared.is_some_and(|n| n > cap) {
272        return Ok(None); // refused unread: dropping the response closes it
273    }
274    let mut buf = Vec::with_capacity(declared.unwrap_or(0).min(cap) as usize);
275    resp.into_reader()
276        .take(cap.saturating_add(1))
277        .read_to_end(&mut buf)
278        .map_err(|e| Failure::Transient(std::io::Error::other(format!("{e} ({what})"))))?;
279    if buf.len() as u64 > cap {
280        return Ok(None);
281    }
282    if declared.is_some_and(|n| n != buf.len() as u64) {
283        return Err(Failure::Transient(std::io::Error::other(format!(
284            "short body: got {} of the {} bytes {what} declared",
285            buf.len(),
286            declared.unwrap_or(0)
287        ))));
288    }
289    Ok(Some(buf))
290}
291
292/// What opening learnt: the length, and the whole file when the host ignored
293/// `Range` on the length probe itself.
294struct Probe {
295    len: u64,
296    whole: Option<Vec<u8>>,
297}
298
299/// An HTTP(S) [`RangeReader`]: kept-alive connections, parallel and coalesced
300/// batches, retries, and the bounded whole-body fallback for hosts that ignore
301/// `Range` (see the module docs).
302pub struct HttpRangeReader {
303    url: String,
304    len: u64,
305    agent: ureq::Agent,
306    policy: FetchPolicy,
307    /// Sent with every request: auth tokens, a custom `User-Agent`.
308    headers: Vec<(String, String)>,
309    /// The whole resource, once a host has answered a range request with `200`
310    /// and a body the reader kept (see the module docs).
311    whole: OnceLock<Vec<u8>>,
312    /// Held while a whole body is read, so concurrent `200`s in one batch keep
313    /// one body, not one per worker.
314    adopting: Mutex<()>,
315}
316
317impl HttpRangeReader {
318    /// Open `url` under [`FetchPolicy::from_env`], with no extra headers.
319    pub fn open(url: &str) -> std::io::Result<Self> {
320        Self::open_with(url, FetchPolicy::from_env())
321    }
322
323    /// [`open`](Self::open) under an explicit fetch policy.
324    pub fn open_with(url: &str, policy: FetchPolicy) -> std::io::Result<Self> {
325        Self::open_with_headers(url, Vec::new(), policy)
326    }
327
328    /// Open `url`, sending `headers` with every request. The length comes from
329    /// a `HEAD`'s `Content-Length`; a host that omits it, or refuses `HEAD`
330    /// (a presigned GET-only URL), is asked with a `bytes=0-0` GET instead,
331    /// whose `Content-Range` carries the total — and a `200` to that GET is the
332    /// whole file, kept under the whole-body rules.
333    pub fn open_with_headers(
334        url: &str,
335        headers: Vec<(String, String)>,
336        policy: FetchPolicy,
337    ) -> std::io::Result<Self> {
338        let agent = ureq::AgentBuilder::new()
339            // Keep a batch's worth of connections open between batches.
340            .max_idle_connections(policy.concurrency.max(4) * 2)
341            .max_idle_connections_per_host(policy.concurrency)
342            .timeout_connect(Duration::from_secs(30))
343            .timeout_read(Duration::from_secs(120))
344            .build();
345        let mut reader = HttpRangeReader {
346            url: url.to_string(),
347            len: 0,
348            agent,
349            policy,
350            headers,
351            whole: OnceLock::new(),
352            adopting: Mutex::new(()),
353        };
354        let probe = reader.probe_len()?;
355        reader.len = probe.len;
356        if let Some(body) = probe.whole {
357            let _ = reader.whole.set(body);
358        }
359        Ok(reader)
360    }
361
362    /// The URL this reader fetches.
363    pub fn url(&self) -> &str {
364        &self.url
365    }
366
367    /// Whether the host ignored `Range` and every read is now served from the
368    /// whole body held in memory.
369    pub fn serves_whole_body(&self) -> bool {
370        self.whole.get().is_some()
371    }
372
373    fn request(&self, method: &str) -> ureq::Request {
374        let mut req = self.agent.request(method, &self.url);
375        for (k, v) in &self.headers {
376            req = req.set(k, v);
377        }
378        req
379    }
380
381    /// Run `call` with the policy's retries for transient failures.
382    fn with_retries<T>(&self, mut call: impl FnMut() -> Result<T, Failure>) -> std::io::Result<T> {
383        let mut attempt = 0;
384        loop {
385            match call() {
386                Ok(v) => return Ok(v),
387                Err(Failure::Permanent(e)) => return Err(e),
388                Err(Failure::Transient(e)) if attempt >= self.policy.retries => {
389                    return Err(if attempt == 0 {
390                        e
391                    } else {
392                        std::io::Error::other(format!("{e} (after {} attempts)", attempt + 1))
393                    });
394                }
395                Err(Failure::Transient(_)) => {
396                    std::thread::sleep(self.policy.backoff(attempt));
397                    attempt += 1;
398                }
399            }
400        }
401    }
402
403    /// The resource length: `HEAD`'s `Content-Length`, else a `bytes=0-0`
404    /// GET's `Content-Range` total, else — when that GET is a `200` — the
405    /// whole body's length, with the body kept.
406    fn probe_len(&self) -> std::io::Result<Probe> {
407        let head_what = format!("HEAD {}", self.url);
408        let head = self.with_retries(|| {
409            self.request("HEAD")
410                .call()
411                .map_err(|e| classify(e, &head_what))
412        });
413        let head_err = match head {
414            Ok(resp) => match resp
415                .header("content-length")
416                .and_then(|v| v.trim().parse::<u64>().ok())
417            {
418                Some(len) => return Ok(Probe { len, whole: None }),
419                None => "no Content-Length".to_string(),
420            },
421            Err(e) => e.to_string(),
422        };
423        let what = format!("bytes 0-0 of {}", self.url);
424        let cap = self.policy.whole_body_cap;
425        let probe = self.with_retries(|| {
426            let resp = self
427                .request("GET")
428                .set("Range", "bytes=0-0")
429                .call()
430                .map_err(|e| classify(e, &what))?;
431            match resp.status() {
432                206 => Ok(resp
433                    .header("content-range")
434                    .and_then(content_range_total)
435                    .map(|len| Probe { len, whole: None })),
436                200 => match read_capped(resp, cap, &what)? {
437                    Some(body) if body.is_empty() => {
438                        Err(Failure::Permanent(std::io::Error::other(format!(
439                            "{} answered 200 with an empty body",
440                            self.url
441                        ))))
442                    }
443                    Some(body) => Ok(Some(Probe {
444                        len: body.len() as u64,
445                        whole: Some(body),
446                    })),
447                    None => Err(Failure::Permanent(whole_body_refusal(&self.url, None, cap))),
448                },
449                s => Err(Failure::Permanent(std::io::Error::other(format!(
450                    "status {s} for {what}"
451                )))),
452            }
453        })?;
454        probe.ok_or_else(|| {
455            std::io::Error::other(format!(
456                "could not determine the file length of {}: {head_err} on HEAD, and no \
457                 Content-Range total on a ranged GET",
458                self.url
459            ))
460        })
461    }
462
463    /// A range past the length the open measured is an error before any
464    /// request: no retry can make the file longer.
465    fn bounds(&self, offset: u64, len: u64) -> std::io::Result<()> {
466        if offset.checked_add(len).is_none_or(|end| end > self.len) {
467            return Err(std::io::Error::new(
468                std::io::ErrorKind::UnexpectedEof,
469                format!(
470                    "range out of bounds: {offset}+{len} past the {} bytes {} reported",
471                    self.len, self.url
472                ),
473            ));
474        }
475        Ok(())
476    }
477
478    /// A `200` answered a range request: keep its body as the whole file when
479    /// it is one (see the module docs). Concurrent `200`s of one batch keep
480    /// one body — the first to take the lock reads it, the rest drop theirs.
481    fn adopt_whole(&self, resp: ureq::Response, what: &str) -> Result<(), Failure> {
482        let _guard = self.adopting.lock().unwrap_or_else(|p| p.into_inner());
483        if self.whole.get().is_some() {
484            return Ok(());
485        }
486        let cap = self.policy.whole_body_cap;
487        let declared = resp
488            .header("content-length")
489            .and_then(|v| v.trim().parse::<u64>().ok());
490        if let Some(n) = declared {
491            if n > cap {
492                return Err(Failure::Permanent(whole_body_refusal(
493                    &self.url,
494                    Some(n),
495                    cap,
496                )));
497            }
498            if n != self.len {
499                return Err(Failure::Permanent(std::io::Error::other(format!(
500                    "the host ignored the Range header and sent {n} bytes of {}, which is not \
501                     the {} bytes it reported for the file",
502                    self.url, self.len
503                ))));
504            }
505        }
506        let body = read_capped(resp, cap, what)?
507            .ok_or_else(|| Failure::Permanent(whole_body_refusal(&self.url, None, cap)))?;
508        if body.len() as u64 != self.len {
509            return Err(Failure::Permanent(std::io::Error::other(format!(
510                "the host ignored the Range header and sent {} bytes of {}, which is not the \
511                 {} bytes it reported for the file",
512                body.len(),
513                self.url,
514                self.len
515            ))));
516        }
517        if self.policy.trace {
518            eprintln!(
519                "[http] {} ignored Range: every read is served from its {}-byte body",
520                self.url,
521                body.len()
522            );
523        }
524        let _ = self.whole.set(body);
525        Ok(())
526    }
527
528    /// One GET for `[offset, offset+len)` (`len > 0`), classified on failure.
529    fn fetch_once(&self, offset: u64, len: u64) -> Result<Vec<u8>, Failure> {
530        let end = offset + len - 1; // HTTP ranges are inclusive
531        let what = format!("bytes {offset}-{end} of {}", self.url);
532        let resp = self
533            .request("GET")
534            .set("Range", &format!("bytes={offset}-{end}"))
535            .call()
536            .map_err(|e| classify(e, &what))?;
537        // A `200 OK` means the host ignored `Range` and is sending the whole
538        // body from offset 0 — never the requested slice. Kept as the file when
539        // it is one and fits the cap; refused by name otherwise.
540        if resp.status() == 200 {
541            self.adopt_whole(resp, &what)?;
542            let whole = self.whole.get().expect("adopted above");
543            return slice_whole(whole, offset, len, &self.url).map_err(Failure::Permanent);
544        }
545        if resp.status() != 206 {
546            return Err(Failure::Permanent(std::io::Error::other(format!(
547                "server answered status {} (expected 206 Partial Content) for {what}; \
548                 the host must support HTTP range requests",
549                resp.status()
550            ))));
551        }
552        // A 206 for some other offset (a broken cache or proxy) is just as wrong.
553        if let Some(start) = resp.header("content-range").and_then(content_range_start) {
554            if start != offset {
555                return Err(Failure::Permanent(std::io::Error::other(format!(
556                    "server answered a Content-Range starting at {start} for a request at \
557                     offset {offset} ({})",
558                    self.url
559                ))));
560            }
561        }
562        let size = usize::try_from(len).map_err(|_| {
563            Failure::Permanent(std::io::Error::other(format!(
564                "a {len}-byte range is too large for memory"
565            )))
566        })?;
567        let mut buf = Vec::with_capacity(size);
568        // Read one byte past the range: an exact body then ends at EOF, which
569        // is what hands the connection back to the agent's pool for reuse.
570        resp.into_reader()
571            .take(len + 1)
572            .read_to_end(&mut buf)
573            .map_err(|e| Failure::Transient(std::io::Error::other(format!("{e} ({what})"))))?;
574        // A truncated transfer (server closed early, range past EOF, proxy
575        // hiccup) must be a clean error, not a silently short buffer handed to
576        // the format parsers — mirroring `SliceReader`'s out-of-bounds error.
577        if (buf.len() as u64) < len {
578            return Err(Failure::Transient(std::io::Error::other(format!(
579                "short range response: got {} of {len} bytes at offset {offset} from {}",
580                buf.len(),
581                self.url
582            ))));
583        }
584        buf.truncate(size);
585        Ok(buf)
586    }
587
588    /// [`fetch_once`](Self::fetch_once) with the policy's retries; a read
589    /// after the whole body was kept is a slice of it.
590    fn fetch(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
591        if let Some(whole) = self.whole.get() {
592            return slice_whole(whole, offset, len, &self.url);
593        }
594        self.with_retries(|| self.fetch_once(offset, len))
595    }
596
597    /// Fetch `spans` in order, up to `concurrency` at a time.
598    fn fetch_spans(&self, spans: &[Span]) -> std::io::Result<Vec<Vec<u8>>> {
599        let workers = self.policy.concurrency.min(spans.len());
600        if workers <= 1 {
601            return spans.iter().map(|s| self.fetch(s.offset, s.len)).collect();
602        }
603        let next = AtomicUsize::new(0);
604        let failed = AtomicBool::new(false);
605        type Slot = Mutex<Option<std::io::Result<Vec<u8>>>>;
606        let slots: Vec<Slot> = spans.iter().map(|_| Mutex::new(None)).collect();
607        let panicked = std::thread::scope(|scope| {
608            let handles: Vec<_> = (0..workers)
609                .map(|_| {
610                    scope.spawn(|| loop {
611                        if failed.load(Ordering::Relaxed) {
612                            break; // the batch has failed: start nothing new
613                        }
614                        let i = next.fetch_add(1, Ordering::Relaxed);
615                        let Some(s) = spans.get(i) else { break };
616                        let r = self.fetch(s.offset, s.len);
617                        if r.is_err() {
618                            failed.store(true, Ordering::Relaxed);
619                        }
620                        *slots[i].lock().unwrap_or_else(|p| p.into_inner()) = Some(r);
621                    })
622                })
623                .collect();
624            handles
625                .into_iter()
626                .map(|h| h.join().is_err())
627                .fold(false, |a, b| a | b)
628        });
629        if panicked {
630            return Err(std::io::Error::other("read_many worker panicked"));
631        }
632        let mut out = Vec::with_capacity(spans.len());
633        let mut first_err = None;
634        for slot in slots {
635            match slot.into_inner().unwrap_or_else(|p| p.into_inner()) {
636                Some(Ok(b)) => out.push(b),
637                Some(Err(e)) => {
638                    first_err.get_or_insert(e);
639                }
640                None => {} // never started: the batch had already failed
641            }
642        }
643        match first_err {
644            Some(e) => Err(e),
645            None if out.len() == spans.len() => Ok(out),
646            None => Err(std::io::Error::other("read_many left a range unfetched")),
647        }
648    }
649}
650
651impl RangeReader for HttpRangeReader {
652    fn len(&self) -> u64 {
653        self.len
654    }
655
656    fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
657        if len == 0 {
658            return Ok(Vec::new());
659        }
660        self.bounds(offset, len)?;
661        if self.policy.trace && self.whole.get().is_none() {
662            eprintln!("[http] read {offset}+{len}");
663        }
664        self.fetch(offset, len)
665    }
666
667    /// The planner's probe-vs-scan hint: how many requests
668    /// [`read_many`](Self::read_many) keeps in flight.
669    fn concurrency(&self) -> usize {
670        self.policy.concurrency
671    }
672
673    /// Coalesce the batch, fetch its requests concurrently, slice the ranges
674    /// back out in request order.
675    fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
676        for &(o, l) in ranges {
677            if l > 0 {
678                self.bounds(o, l)?;
679            }
680        }
681        if let Some(whole) = self.whole.get() {
682            return ranges
683                .iter()
684                .map(|&(o, l)| slice_whole(whole, o, l, &self.url))
685                .collect();
686        }
687        let (spans, place) = coalesce(ranges, self.policy.coalesce_gap, self.policy.max_span)?;
688        if self.policy.trace {
689            let bytes: u64 = spans.iter().map(|s| s.len).sum();
690            eprintln!(
691                "[http] batch of {} range(s) -> {} request(s), {bytes} bytes, {} in flight",
692                ranges.len(),
693                spans.len(),
694                self.policy.concurrency.min(spans.len())
695            );
696        }
697        let blobs = self.fetch_spans(&spans)?;
698        Ok(place
699            .into_iter()
700            .zip(ranges)
701            .map(|(p, &(_, len))| match p {
702                None => Vec::new(),
703                Some((si, at)) => {
704                    let at = at as usize;
705                    blobs[si][at..at + len as usize].to_vec()
706                }
707            })
708            .collect())
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use std::io::Write;
716    use std::net::TcpListener;
717    use std::sync::atomic::AtomicU64;
718    use std::sync::Arc;
719
720    /// How the throwaway test server treats range requests.
721    #[derive(Clone, Copy)]
722    enum ServerMode {
723        /// A real range host: `206` with exactly the requested slice.
724        HonorRange,
725        /// A plain static server: ignores `Range`, replies `200` + whole body.
726        IgnoreRange,
727        /// Ignores `Range` and sends the body with no `Content-Length`, ending
728        /// it by closing the connection.
729        IgnoreRangeUnsized,
730        /// A range host whose `HEAD` carries no `Content-Length` (some object
731        /// stores): the length must come from a ranged GET.
732        HeadWithoutLength,
733        /// `HEAD` without a length AND `Range` ignored: the length probe itself
734        /// receives the whole file.
735        HeadWithoutLengthIgnoreRange,
736        /// A flaky host: claims the full range (`206` + Content-Length) but
737        /// closes the connection after sending only half the bytes.
738        TruncateBody,
739        /// A broken host: `500` on every GET.
740        Error500,
741        /// `404` on every GET: permanent, never retried.
742        NotFound,
743        /// `503` for the first `n` GETs, then a real range host.
744        Unavailable(u64),
745        /// A broken cache: `206` with a `Content-Range` one byte off.
746        WrongOffset,
747        /// A real range host, except `404` for a request starting at this offset.
748        FailAt(usize),
749        /// A real range host that answers `401` unless the request carries
750        /// `x-token: secret`.
751        RequireHeader,
752    }
753
754    /// What the server saw: GETs served, and the most it had in flight at once.
755    ///
756    /// A GET is in flight from the moment its handler counts it until the
757    /// moment it starts to answer — not until the handler thread finishes. A
758    /// client may send its next request as soon as it has read the answer's
759    /// Content-Length bytes, so counting until after the write measured the
760    /// handler's tail: a strictly sequential client was sometimes counted
761    /// twice (2 for `concurrency_one_is_sequential` in CI on #362; 5 for 4 in
762    /// `read_many_overlaps_requests_up_to_the_bound` under load).
763    #[derive(Default)]
764    struct Stats {
765        gets: AtomicU64,
766        in_flight: AtomicU64,
767        max_in_flight: AtomicU64,
768    }
769
770    /// A throwaway localhost HTTP/1.1 server over the given bytes, with the
771    /// given range behavior; one thread per connection, each GET held for
772    /// `delay` so concurrent requests overlap. Returns the base URL and the
773    /// server's counters; the server thread is detached.
774    fn serve_with(data: Vec<u8>, mode: ServerMode, delay: Duration) -> (String, Arc<Stats>) {
775        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
776        let url = format!("http://{}/", listener.local_addr().unwrap());
777        let stats = Arc::new(Stats::default());
778        let data = Arc::new(data);
779        let st = stats.clone();
780        std::thread::spawn(move || {
781            for stream in listener.incoming() {
782                let Ok(stream) = stream else { continue };
783                let (data, st) = (data.clone(), st.clone());
784                std::thread::spawn(move || handle(stream, &data, mode, delay, &st));
785            }
786        });
787        (url, stats)
788    }
789
790    fn serve(data: Vec<u8>, mode: ServerMode) -> String {
791        serve_with(data, mode, Duration::ZERO).0
792    }
793
794    fn handle(
795        mut stream: std::net::TcpStream,
796        data: &[u8],
797        mode: ServerMode,
798        delay: Duration,
799        st: &Stats,
800    ) {
801        // Read the request head (until CRLFCRLF).
802        let mut req = Vec::new();
803        let mut byte = [0u8; 1];
804        while !req.ends_with(b"\r\n\r\n") {
805            use std::io::Read as _;
806            if stream.read(&mut byte).unwrap_or(0) == 0 {
807                break;
808            }
809            req.push(byte[0]);
810        }
811        let text = String::from_utf8_lossy(&req);
812        let is_head = text.starts_with("HEAD");
813        let range = text.lines().find_map(|l| {
814            let l = l.to_ascii_lowercase();
815            l.strip_prefix("range: bytes=")
816                .map(|r| r.trim().to_string())
817        });
818        let authorized = text
819            .lines()
820            .any(|l| l.eq_ignore_ascii_case("x-token: secret"));
821
822        let total = data.len();
823        let status_only = |stream: &mut std::net::TcpStream, line: &str| {
824            let _ = stream.write_all(
825                format!("HTTP/1.1 {line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
826                    .as_bytes(),
827            );
828        };
829        if matches!(mode, ServerMode::RequireHeader) && !authorized {
830            status_only(&mut stream, "401 Unauthorized");
831            return;
832        }
833        if is_head {
834            let h = if matches!(
835                mode,
836                ServerMode::HeadWithoutLength | ServerMode::HeadWithoutLengthIgnoreRange
837            ) {
838                "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".to_string()
839            } else {
840                format!(
841                    "HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n"
842                )
843            };
844            let _ = stream.write_all(h.as_bytes());
845            return;
846        }
847        let n = st.gets.fetch_add(1, Ordering::SeqCst) + 1;
848        let now = st.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
849        st.max_in_flight.fetch_max(now, Ordering::SeqCst);
850        std::thread::sleep(delay);
851        // Out of flight BEFORE the first byte of the answer: the client cannot
852        // send its next request until it has read this one, so the decrement
853        // happens before that request is counted (see `Stats`).
854        st.in_flight.fetch_sub(1, Ordering::SeqCst);
855        let ignores_range = matches!(
856            mode,
857            ServerMode::IgnoreRange
858                | ServerMode::IgnoreRangeUnsized
859                | ServerMode::HeadWithoutLengthIgnoreRange
860        );
861        match (mode, range) {
862            (ServerMode::Error500, _) => status_only(&mut stream, "500 Internal Server Error"),
863            (ServerMode::NotFound, _) => status_only(&mut stream, "404 Not Found"),
864            (ServerMode::Unavailable(k), _) if n <= k => {
865                status_only(&mut stream, "503 Service Unavailable")
866            }
867            (ServerMode::FailAt(at), Some(r)) if r.starts_with(&format!("{at}-")) => {
868                status_only(&mut stream, "404 Not Found")
869            }
870            (ServerMode::IgnoreRangeUnsized, _) => {
871                let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n");
872                let _ = stream.write_all(data);
873            }
874            (_, Some(r)) if !ignores_range => {
875                let (a, b) = r.split_once('-').unwrap();
876                let a: usize = a.parse().unwrap();
877                let b: usize = b.parse().unwrap();
878                let slice = &data[a.min(total)..=b.min(total - 1)];
879                let claimed = if matches!(mode, ServerMode::WrongOffset) {
880                    a + 1
881                } else {
882                    a
883                };
884                let h = format!(
885                    "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {claimed}-{b}/{total}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
886                    slice.len()
887                );
888                let _ = stream.write_all(h.as_bytes());
889                let sent = if matches!(mode, ServerMode::TruncateBody) {
890                    &slice[..slice.len() / 2] // lie, then hang up early
891                } else {
892                    slice
893                };
894                let _ = stream.write_all(sent);
895            }
896            // Ignore Range (or no Range sent): 200 with the whole body.
897            _ => {
898                let h = format!(
899                    "HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n"
900                );
901                let _ = stream.write_all(h.as_bytes());
902                let _ = stream.write_all(data);
903            }
904        }
905    }
906
907    /// The default policy with quick retries, so failure tests stay fast.
908    fn quick() -> FetchPolicy {
909        FetchPolicy {
910            backoff_ms: 1,
911            ..FetchPolicy::DEFAULT
912        }
913    }
914
915    #[test]
916    fn reads_exact_ranges_from_a_range_host() {
917        let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
918        let url = serve(data.clone(), ServerMode::HonorRange);
919        let r = HttpRangeReader::open(&url).unwrap();
920        assert_eq!(r.len(), 1000);
921        assert_eq!(r.read_at(0, 4).unwrap(), &data[0..4]);
922        assert_eq!(r.read_at(500, 20).unwrap(), &data[500..520]);
923        assert_eq!(r.read_at(996, 4).unwrap(), &data[996..1000]);
924        assert!(r.read_at(0, 0).unwrap().is_empty());
925        assert!(!r.serves_whole_body());
926    }
927
928    /// Proves the `https://` transport works end-to-end against a real host that
929    /// honors Range. Network-dependent, so `#[ignore]`d — CI never touches the
930    /// network. Run on demand: `cargo test -p rete-core --features http -- --ignored https`.
931    #[test]
932    #[ignore = "hits the network (httpbin); run with --ignored"]
933    fn reads_ranges_over_https() {
934        let url = "https://httpbin.org/range/2048";
935        let r = HttpRangeReader::open(url).unwrap();
936        assert!(r.len() >= 2048);
937        let chunk = r.read_at(100, 16).unwrap();
938        assert_eq!(chunk.len(), 16);
939    }
940
941    /// G6: a host that ignores `Range` answers `200` with the whole body. It is
942    /// never mis-read as the requested slice: under the cap it is kept, and
943    /// every read after it — single or batched — is a slice of it, with no
944    /// further request.
945    #[test]
946    fn keeps_the_whole_body_of_a_host_that_ignores_range() {
947        let data: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
948        let (url, stats) = serve_with(data.clone(), ServerMode::IgnoreRange, Duration::ZERO);
949        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
950        assert_eq!(r.len(), 5000);
951        assert_eq!(r.read_at(50, 10).unwrap(), &data[50..60]);
952        assert!(r.serves_whole_body());
953        assert_eq!(r.read_at(4990, 10).unwrap(), &data[4990..5000]);
954        let got = r.read_many(&[(0, 4), (3000, 100), (7, 0)]).unwrap();
955        assert_eq!(got[0], &data[0..4]);
956        assert_eq!(got[1], &data[3000..3100]);
957        assert!(got[2].is_empty());
958        assert_eq!(
959            stats.gets.load(Ordering::SeqCst),
960            1,
961            "one download, then memory"
962        );
963        // Bounds still hold against the length the open measured.
964        assert!(r.read_at(4999, 2).is_err());
965    }
966
967    /// The same for a body without `Content-Length` (ended by closing the
968    /// connection): read, measured, kept.
969    #[test]
970    fn keeps_an_unsized_whole_body_too() {
971        let data: Vec<u8> = (0..=255u8).cycle().take(3000).collect();
972        let (url, stats) = serve_with(data.clone(), ServerMode::IgnoreRangeUnsized, Duration::ZERO);
973        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
974        assert_eq!(
975            r.read_many(&[(10, 5), (2000, 5)]).unwrap()[1],
976            &data[2000..2005]
977        );
978        assert_eq!(stats.gets.load(Ordering::SeqCst), 1);
979    }
980
981    /// Above the cap the body is refused by name — permanently, so it is not
982    /// downloaded again by a retry — and with a declared length, unread.
983    #[test]
984    fn refuses_a_whole_body_over_the_cap() {
985        let small_cap = FetchPolicy {
986            whole_body_cap: 1000,
987            ..quick()
988        };
989        let data: Vec<u8> = (0..=255u8).cycle().take(4000).collect();
990        for mode in [ServerMode::IgnoreRange, ServerMode::IgnoreRangeUnsized] {
991            let (url, stats) = serve_with(data.clone(), mode, Duration::ZERO);
992            let r = HttpRangeReader::open_with(&url, small_cap).unwrap();
993            let err = r.read_at(50, 10).unwrap_err().to_string();
994            assert!(
995                err.contains("ignored the Range header") && err.contains("not kept in memory"),
996                "{err}"
997            );
998            assert_eq!(stats.gets.load(Ordering::SeqCst), 1, "permanent: no retry");
999            assert!(!r.serves_whole_body());
1000            // A batch is refused the same way.
1001            assert!(r.read_many(&[(0, 1), (2000, 1)]).is_err());
1002        }
1003        // The real cap is the browser readers'.
1004        assert_eq!(FetchPolicy::DEFAULT.whole_body_cap, 256 << 20);
1005        let msg = whole_body_refusal("u", Some(300 << 20), WHOLE_BODY_CAP).to_string();
1006        assert!(
1007            msg.contains("256 MiB") && msg.contains("status 200"),
1008            "{msg}"
1009        );
1010    }
1011
1012    /// A `HEAD` without a length is answered by a ranged GET's
1013    /// `Content-Range`; and when that GET is itself a `200`, the open keeps the
1014    /// file it was sent and no read ever goes out again.
1015    #[test]
1016    fn measures_the_length_without_a_head_content_length() {
1017        let data: Vec<u8> = (0..=255u8).cycle().take(2048).collect();
1018        let (url, stats) = serve_with(data.clone(), ServerMode::HeadWithoutLength, Duration::ZERO);
1019        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1020        assert_eq!(r.len(), 2048);
1021        assert_eq!(r.read_at(100, 8).unwrap(), &data[100..108]);
1022        assert!(!r.serves_whole_body());
1023        assert_eq!(stats.gets.load(Ordering::SeqCst), 2, "probe + read");
1024
1025        let (url, stats) = serve_with(
1026            data.clone(),
1027            ServerMode::HeadWithoutLengthIgnoreRange,
1028            Duration::ZERO,
1029        );
1030        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1031        assert_eq!(r.len(), 2048);
1032        assert!(r.serves_whole_body());
1033        assert_eq!(r.read_at(2000, 48).unwrap(), &data[2000..2048]);
1034        assert_eq!(
1035            stats.gets.load(Ordering::SeqCst),
1036            1,
1037            "the probe was the file"
1038        );
1039    }
1040
1041    /// Extra headers ride on every request, the length probe included.
1042    #[test]
1043    fn sends_the_extra_headers_on_every_request() {
1044        let data: Vec<u8> = (0..100u8).collect();
1045        let url = serve(data.clone(), ServerMode::RequireHeader);
1046        assert!(HttpRangeReader::open_with(&url, quick()).is_err());
1047        let headers = vec![("x-token".to_string(), "secret".to_string())];
1048        let r = HttpRangeReader::open_with_headers(&url, headers, quick()).unwrap();
1049        assert_eq!(r.len(), 100);
1050        assert_eq!(r.read_many(&[(0, 2), (90, 10)]).unwrap()[1], &data[90..100]);
1051    }
1052
1053    #[test]
1054    fn rejects_a_truncated_range_response() {
1055        // A 206 that claims the slice but delivers only part of it (dropped
1056        // connection, range past EOF, broken proxy) must surface as a clean
1057        // error — never as a silently short buffer.
1058        let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
1059        let (url, stats) = serve_with(data, ServerMode::TruncateBody, Duration::ZERO);
1060        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1061        // ureq itself flags the Content-Length mismatch ("body closed before
1062        // all bytes were read"); our own check is the backstop for transports
1063        // that don't. Either way: an error, never a short buffer.
1064        let err = r.read_at(100, 40).unwrap_err();
1065        let msg = err.to_string();
1066        assert!(
1067            msg.contains("short range response") || msg.contains("closed before"),
1068            "expected a truncation error, got: {err}"
1069        );
1070        assert_eq!(
1071            stats.gets.load(Ordering::SeqCst),
1072            3,
1073            "a truncated body is transient: retried twice"
1074        );
1075        // And a range that runs past EOF errors too, before any request.
1076        let data: Vec<u8> = (0..100u8).collect();
1077        let (url, stats) = serve_with(data, ServerMode::HonorRange, Duration::ZERO);
1078        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1079        let err = r.read_at(90, 20).unwrap_err();
1080        assert!(err.to_string().contains("out of bounds"), "{err}");
1081        assert!(r.read_many(&[(0, 1), (90, 20)]).is_err());
1082        assert_eq!(stats.gets.load(Ordering::SeqCst), 0);
1083    }
1084
1085    #[test]
1086    fn server_errors_are_clean_errors() {
1087        let data: Vec<u8> = (0..100u8).collect();
1088        let (url, stats) = serve_with(data, ServerMode::Error500, Duration::ZERO);
1089        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1090        // HEAD succeeded; the GET's 500 must come back as an error, not bytes.
1091        let err = r.read_at(0, 10).unwrap_err();
1092        assert!(err.to_string().contains("HTTP 500"), "{err}");
1093        assert!(err.to_string().contains("after 3 attempts"), "{err}");
1094        assert_eq!(stats.gets.load(Ordering::SeqCst), 3);
1095    }
1096
1097    #[test]
1098    fn a_missing_file_is_not_retried() {
1099        let (url, stats) = serve_with(vec![0; 100], ServerMode::NotFound, Duration::ZERO);
1100        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1101        let err = r.read_at(0, 10).unwrap_err();
1102        assert!(err.to_string().contains("HTTP 404"), "{err}");
1103        assert_eq!(stats.gets.load(Ordering::SeqCst), 1);
1104    }
1105
1106    #[test]
1107    fn a_transient_failure_is_retried_and_recovers() {
1108        let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
1109        let (url, stats) = serve_with(data.clone(), ServerMode::Unavailable(2), Duration::ZERO);
1110        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1111        assert_eq!(r.read_at(10, 30).unwrap(), &data[10..40]);
1112        assert_eq!(stats.gets.load(Ordering::SeqCst), 3, "two 503s, then 206");
1113        // With retries off the same outage is an error.
1114        let (url, _) = serve_with(data, ServerMode::Unavailable(1), Duration::ZERO);
1115        let none = FetchPolicy {
1116            retries: 0,
1117            ..quick()
1118        };
1119        let r = HttpRangeReader::open_with(&url, none).unwrap();
1120        assert!(r.read_at(10, 30).unwrap_err().to_string().contains("503"));
1121    }
1122
1123    #[test]
1124    fn rejects_a_range_answered_for_the_wrong_offset() {
1125        let (url, _) = serve_with(vec![7; 1000], ServerMode::WrongOffset, Duration::ZERO);
1126        let r = HttpRangeReader::open_with(&url, quick()).unwrap();
1127        let err = r.read_at(100, 10).unwrap_err();
1128        assert!(
1129            err.to_string().contains("Content-Range starting at 101"),
1130            "{err}"
1131        );
1132    }
1133
1134    /// The engine's batch goes out concurrently, bounded by the policy, and
1135    /// comes back in request order with exact bytes.
1136    #[test]
1137    fn read_many_overlaps_requests_up_to_the_bound() {
1138        let data: Vec<u8> = (0..=255u8).cycle().take(1 << 20).collect();
1139        let (url, stats) = serve_with(
1140            data.clone(),
1141            ServerMode::HonorRange,
1142            Duration::from_millis(40),
1143        );
1144        let policy = FetchPolicy {
1145            concurrency: 4,
1146            coalesce_gap: 0,
1147            ..quick()
1148        };
1149        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1150        // 12 ranges 64 KiB apart, given out of order, plus an empty one.
1151        let mut ranges: Vec<(u64, u64)> = (0..12).map(|i| (i * 65_536 + 7, 100)).collect();
1152        ranges.reverse();
1153        ranges.insert(3, (5, 0));
1154        let t = std::time::Instant::now();
1155        let got = r.read_many(&ranges).unwrap();
1156        let took = t.elapsed();
1157        assert_eq!(got.len(), ranges.len());
1158        for (&(o, l), b) in ranges.iter().zip(&got) {
1159            assert_eq!(b.as_slice(), &data[o as usize..(o + l) as usize]);
1160        }
1161        assert_eq!(stats.gets.load(Ordering::SeqCst), 12);
1162        assert_eq!(
1163            stats.max_in_flight.load(Ordering::SeqCst),
1164            4,
1165            "bounded fan-out"
1166        );
1167        // 12 requests, 4 at a time, 40 ms each: 3 waves, not 12.
1168        assert!(took < Duration::from_millis(12 * 40), "{took:?}");
1169    }
1170
1171    /// `RETE_HTTP_CONCURRENCY=1` behaves exactly like the old sequential reader.
1172    #[test]
1173    fn concurrency_one_is_sequential() {
1174        let data: Vec<u8> = (0..=255u8).cycle().take(4096).collect();
1175        let (url, stats) = serve_with(data, ServerMode::HonorRange, Duration::from_millis(5));
1176        let policy = FetchPolicy {
1177            concurrency: 1,
1178            coalesce_gap: 0,
1179            ..quick()
1180        };
1181        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1182        assert_eq!(r.concurrency(), 1);
1183        r.read_many(&[(0, 10), (1000, 10), (3000, 10)]).unwrap();
1184        assert_eq!(stats.max_in_flight.load(Ordering::SeqCst), 1);
1185        assert_eq!(stats.gets.load(Ordering::SeqCst), 3);
1186    }
1187
1188    /// Nearby ranges share one request; far ones do not.
1189    #[test]
1190    fn read_many_coalesces_nearby_ranges() {
1191        let data: Vec<u8> = (0..=255u8).cycle().take(1 << 20).collect();
1192        let (url, stats) = serve_with(data.clone(), ServerMode::HonorRange, Duration::ZERO);
1193        let policy = FetchPolicy {
1194            coalesce_gap: 1024,
1195            ..quick()
1196        };
1197        let r = HttpRangeReader::open_with(&url, policy).unwrap();
1198        let ranges = [(5000, 100), (100, 50), (200, 10), (190, 30), (6000, 1)];
1199        let got = r.read_many(&ranges).unwrap();
1200        for (&(o, l), b) in ranges.iter().zip(&got) {
1201            assert_eq!(b.as_slice(), &data[o as usize..(o + l) as usize]);
1202        }
1203        // [100..220) is one request (overlapping and close); [5000..6001) another.
1204        assert_eq!(stats.gets.load(Ordering::SeqCst), 2);
1205    }
1206
1207    /// One failing range fails the whole batch, and the error names it.
1208    #[test]
1209    fn read_many_fails_the_batch_on_one_bad_range() {
1210        let data: Vec<u8> = (0..100u8).collect();
1211        let url = serve(data, ServerMode::FailAt(50));
1212        let r = HttpRangeReader::open_with(
1213            &url,
1214            FetchPolicy {
1215                coalesce_gap: 0,
1216                ..quick()
1217            },
1218        )
1219        .unwrap();
1220        let err = r.read_many(&[(0, 10), (50, 10), (20, 10)]).unwrap_err();
1221        assert!(
1222            err.to_string().contains("HTTP 404 for bytes 50-59"),
1223            "{err}"
1224        );
1225        assert!(r.read_many(&[(0, 10), (20, 10)]).is_ok());
1226    }
1227
1228    #[test]
1229    fn coalesce_plans_spans_and_placements() {
1230        let (spans, place) = coalesce(&[(10, 5), (0, 0), (18, 4), (100, 1)], 3, 1 << 20).unwrap();
1231        assert_eq!(
1232            spans,
1233            vec![
1234                Span {
1235                    offset: 10,
1236                    len: 12
1237                },
1238                Span {
1239                    offset: 100,
1240                    len: 1
1241                }
1242            ]
1243        );
1244        assert_eq!(place, vec![Some((0, 0)), None, Some((0, 8)), Some((1, 0))]);
1245        // The span cap keeps a merge from growing without bound.
1246        let (spans, _) = coalesce(&[(0, 10), (10, 10), (20, 10)], 0, 20).unwrap();
1247        assert_eq!(spans.len(), 2);
1248        assert!(coalesce(&[(u64::MAX, 2)], 0, 10).is_err());
1249    }
1250
1251    #[test]
1252    fn parses_content_range() {
1253        assert_eq!(content_range_start("bytes 100-199/1000"), Some(100));
1254        assert_eq!(content_range_start(" bytes 0-0/*"), Some(0));
1255        assert_eq!(content_range_start("items 1-2/3"), None);
1256        assert_eq!(content_range_total("bytes 0-0/1000"), Some(1000));
1257        assert_eq!(content_range_total("bytes 0-0/*"), None);
1258    }
1259
1260    #[test]
1261    fn retryable_statuses() {
1262        for c in [408, 429, 500, 502, 503, 504] {
1263            assert!(retryable_status(c), "{c}");
1264        }
1265        for c in [400, 403, 404, 416, 501, 505] {
1266            assert!(!retryable_status(c), "{c}");
1267        }
1268    }
1269}