Skip to main content

link_r/fetch/
http.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! The HTTP(S) fetcher (rustls), generic over a consumer-injected [`AuthProvider`].
5//!
6//! This is where async auth refresh flows through the GAT [`Fetcher`] unboxed:
7//! `needs_refresh()` (atomic) → maybe `refresh().await` (the only async auth step)
8//! → `credential()` (sync, lock-free) → apply header → GET.
9
10use crate::auth::{AuthProvider, Credential};
11use crate::error::{Error, Result};
12use crate::fetch::{FetchMeta, FetchOptions, Fetched, Fetcher};
13use crate::payload::DocPayload;
14use crate::resource::{Resource, ResourceKind};
15use bytes::BytesMut;
16use compact_str::CompactString;
17use futures::StreamExt;
18use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, ETAG, IF_NONE_MATCH, USER_AGENT};
19use std::time::{Duration, Instant};
20
21/// Default User-Agent sent with crawl requests.
22pub(crate) const DEFAULT_UA: &str = concat!("link-r/", env!("CARGO_PKG_VERSION"));
23/// Connection-establishment timeout for the default client.
24const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
25/// Whole-request timeout (headers + body) for the default client.
26const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
27/// Maximum redirects the default client follows before erroring.
28const MAX_REDIRECTS: usize = 5;
29
30/// An HTTP(S) fetcher over `reqwest` + rustls.
31#[derive(Debug)]
32pub struct HttpFetcher<A: AuthProvider> {
33    client: reqwest::Client,
34    auth: A,
35    user_agent: CompactString,
36}
37
38/// The client [`HttpFetcher::new`] builds: rustls, a bounded redirect policy,
39/// connect/request timeouts. Built once per facade index and reused (its
40/// connection pool and TLS configuration with it) across runs.
41pub(crate) fn default_client() -> Result<reqwest::Client> {
42    reqwest::Client::builder()
43        .user_agent(DEFAULT_UA)
44        .connect_timeout(CONNECT_TIMEOUT)
45        .timeout(REQUEST_TIMEOUT)
46        .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))
47        .build()
48        .map_err(|e| Error::backend("reqwest", e.to_string()))
49}
50
51impl<A: AuthProvider> HttpFetcher<A> {
52    /// Build a fetcher with a default client: rustls, a bounded redirect policy,
53    /// and connect/request timeouts.
54    ///
55    /// # Errors
56    /// Returns [`Error::Backend`] if the HTTP client cannot be constructed.
57    pub fn new(auth: A) -> Result<Self> {
58        let client = default_client()?;
59        Ok(Self {
60            client,
61            auth,
62            user_agent: CompactString::from(DEFAULT_UA),
63        })
64    }
65
66    /// Build with a caller-provided client (e.g. custom timeouts/proxy).
67    #[must_use]
68    pub fn with_client(client: reqwest::Client, auth: A) -> Self {
69        Self {
70            client,
71            auth,
72            user_agent: CompactString::from(DEFAULT_UA),
73        }
74    }
75
76    /// Override the User-Agent.
77    #[must_use]
78    pub fn user_agent(mut self, ua: impl Into<CompactString>) -> Self {
79        self.user_agent = ua.into();
80        self
81    }
82}
83
84fn map_reqwest(e: &reqwest::Error, started: Instant) -> Error {
85    if e.is_timeout() {
86        Error::Timeout {
87            duration_ms: started.elapsed().as_millis() as u64,
88        }
89    } else {
90        Error::backend("reqwest", e.to_string())
91    }
92}
93
94/// A body-stream error that can be turned into a crate [`Error`], carrying the
95/// request start so a timeout mid-body reports a real elapsed duration. Static
96/// dispatch keeps [`collect_capped`] free of `dyn`.
97trait StreamErr {
98    fn into_error(self, started: Instant) -> Error;
99}
100
101impl StreamErr for reqwest::Error {
102    fn into_error(self, started: Instant) -> Error {
103        map_reqwest(&self, started)
104    }
105}
106
107#[cfg(test)]
108impl StreamErr for std::convert::Infallible {
109    fn into_error(self, _started: Instant) -> Error {
110        match self {}
111    }
112}
113
114/// Drain a byte stream into contiguous `Bytes`, enforcing an incremental size cap
115/// so a chunked response (no `Content-Length`) cannot bypass `max_bytes`. Factored
116/// out for direct unit testing over any chunk stream.
117async fn collect_capped<S, E>(
118    mut stream: S,
119    hint: Option<u64>,
120    max: Option<u64>,
121    status: u16,
122    started: Instant,
123) -> Result<bytes::Bytes>
124where
125    S: futures::Stream<Item = std::result::Result<bytes::Bytes, E>> + Unpin,
126    E: StreamErr,
127{
128    // Pre-size to the smaller of the length hint and the cap, so a hostile hint
129    // can't force a huge allocation.
130    let cap = match (hint, max) {
131        (Some(h), Some(m)) => h.min(m),
132        (Some(h), None) => h,
133        (None, _) => 0,
134    };
135    let cap = usize::try_from(cap).unwrap_or(0).min(1 << 20);
136    let mut body = BytesMut::with_capacity(cap);
137    while let Some(chunk) = stream.next().await {
138        let chunk = chunk.map_err(|e| e.into_error(started))?;
139        if let Some(max) = max {
140            if body.len() as u64 + chunk.len() as u64 > max {
141                return Err(Error::http(status, format!("body exceeds max {max} bytes")));
142            }
143        }
144        body.extend_from_slice(&chunk);
145    }
146    Ok(body.freeze())
147}
148
149impl<A: AuthProvider> Fetcher for HttpFetcher<A> {
150    type FetchFuture<'a>
151        = impl std::future::Future<Output = Result<Fetched<'a>>> + Send + 'a
152    where
153        Self: 'a;
154
155    fn fetch<'a>(
156        &'a self,
157        resource: &'a Resource,
158        opts: FetchOptions<'a>,
159    ) -> Self::FetchFuture<'a> {
160        async move {
161            let started = Instant::now();
162            // The only async auth step — its concrete future keeps this unboxed.
163            if self.auth.needs_refresh() {
164                self.auth.refresh().await?;
165            }
166
167            let ua = opts.user_agent.unwrap_or(self.user_agent.as_str());
168            let mut req = self.client.get(resource.url.clone()).header(USER_AGENT, ua);
169
170            match self.auth.credential(resource) {
171                Credential::Bearer(token) => {
172                    req = req.header(AUTHORIZATION, format!("Bearer {token}"));
173                }
174                Credential::Header { name, value } => {
175                    req = req.header(name, value.as_ref());
176                }
177                Credential::Anonymous | Credential::Presigned => {}
178            }
179            if let Some(etag) = opts.if_none_match {
180                req = req.header(IF_NONE_MATCH, etag);
181            }
182
183            let resp = req.send().await.map_err(|e| map_reqwest(&e, started))?;
184            let status = resp.status().as_u16();
185            match status {
186                304 => return Err(Error::not_modified(resource.url.as_str())),
187                401 => return Err(Error::unauthenticated(resource.url.as_str())),
188                403 => return Err(Error::permission_denied(resource.url.as_str())),
189                429 => return Err(rate_limited(&resp)),
190                s if s >= 400 => {
191                    return Err(Error::http(s, resource.url.as_str()));
192                }
193                _ => {}
194            }
195
196            // The URL after any redirects reqwest followed; only surfaced when it
197            // actually differs, so the crawler keys/scopes on the real location.
198            let final_url = (resp.url() != &resource.url).then(|| resp.url().clone());
199            let classify_path = resp.url().path().to_owned();
200
201            // Header first, refined by the path: a generic `text/plain` (the
202            // answer raw.githubusercontent gives for everything) yields to a
203            // more specific extension, while a real header claim stands.
204            let kind = resp
205                .headers()
206                .get(CONTENT_TYPE)
207                .and_then(|v| v.to_str().ok())
208                .map_or(ResourceKind::Unknown, ResourceKind::from_content_type)
209                .refine_with_path(&classify_path);
210
211            let etag = resp
212                .headers()
213                .get(ETAG)
214                .and_then(|v| v.to_str().ok())
215                .map(CompactString::from);
216
217            // Enforce max_bytes early via Content-Length when present, then stream
218            // the body with an incremental cap so a chunked response can't bypass it.
219            let hint = resp.content_length();
220            if let (Some(max), Some(len)) = (opts.max_bytes, hint) {
221                if len > max {
222                    return Err(Error::http(status, format!("body {len} exceeds max {max}")));
223                }
224            }
225            let bytes =
226                collect_capped(resp.bytes_stream(), hint, opts.max_bytes, status, started).await?;
227
228            Ok(Fetched {
229                meta: FetchMeta {
230                    kind,
231                    etag,
232                    status,
233                    final_url,
234                },
235                payload: DocPayload::Owned(bytes),
236            })
237        }
238    }
239}
240
241/// Floor and ceiling for a server-suggested retry delay. The ceiling keeps a
242/// hostile or misconfigured `Retry-After: <far future date>` from parking a
243/// refresh for hours; the crawl loop applies its own tighter clamp on top.
244const RETRY_AFTER_MIN_MS: u64 = 1_000;
245const RETRY_AFTER_MAX_MS: u64 = 300_000;
246
247fn rate_limited(resp: &reqwest::Response) -> Error {
248    // Retry-After in either RFC 9110 form, else x-ratelimit-reset (epoch
249    // seconds); clamped into a sane window.
250    let now_ms = graph_r::fsio::now_ms();
251    let header = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok());
252    let retry_after_ms = header("retry-after")
253        .and_then(|v| objstore_r::net::retry_after(v, now_ms))
254        .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
255        .or_else(|| {
256            header("x-ratelimit-reset")
257                .and_then(|s| s.trim().parse::<u64>().ok())
258                .map(|epoch_s| epoch_s.saturating_mul(1000).saturating_sub(now_ms))
259        })
260        .unwrap_or(RETRY_AFTER_MIN_MS)
261        .clamp(RETRY_AFTER_MIN_MS, RETRY_AFTER_MAX_MS);
262    Error::rate_limited(retry_after_ms)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::auth::AnonymousAuth;
269
270    #[test]
271    fn constructs_with_anonymous_auth() {
272        let fetcher = HttpFetcher::new(AnonymousAuth).unwrap();
273        assert_eq!(fetcher.user_agent, CompactString::from(DEFAULT_UA));
274    }
275
276    #[test]
277    fn user_agent_override() {
278        let fetcher = HttpFetcher::new(AnonymousAuth)
279            .unwrap()
280            .user_agent("my-bot/1.0");
281        assert_eq!(fetcher.user_agent, "my-bot/1.0");
282    }
283
284    fn chunks(
285        parts: &[&[u8]],
286    ) -> impl futures::Stream<Item = std::result::Result<bytes::Bytes, std::convert::Infallible>>
287    {
288        let owned: Vec<_> = parts
289            .iter()
290            .map(|p| Ok(bytes::Bytes::copy_from_slice(p)))
291            .collect();
292        futures::stream::iter(owned)
293    }
294
295    #[tokio::test]
296    async fn collect_capped_under_cap_passes() {
297        let started = Instant::now();
298        let out = collect_capped(
299            chunks(&[b"hello ", b"world"]),
300            None,
301            Some(1024),
302            200,
303            started,
304        )
305        .await
306        .unwrap();
307        assert_eq!(&out[..], b"hello world");
308    }
309
310    #[tokio::test]
311    async fn collect_capped_over_cap_errors_without_length_hint() {
312        // The chunked-bypass regression: no length hint, cap exceeded mid-stream.
313        let started = Instant::now();
314        let err = collect_capped(
315            chunks(&[b"aaaa", b"bbbb", b"cccc"]),
316            None,
317            Some(8),
318            200,
319            started,
320        )
321        .await
322        .unwrap_err();
323        match err {
324            Error::Http { status, .. } => assert_eq!(status, 200),
325            other => panic!("expected Http cap error, got {other:?}"),
326        }
327    }
328
329    #[tokio::test]
330    async fn collect_capped_no_max_reads_all() {
331        let started = Instant::now();
332        let out = collect_capped(chunks(&[b"a", b"b", b"c"]), Some(3), None, 200, started)
333            .await
334            .unwrap();
335        assert_eq!(&out[..], b"abc");
336    }
337}