1use 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
21pub(crate) const DEFAULT_UA: &str = concat!("link-r/", env!("CARGO_PKG_VERSION"));
23const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
25const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
27const MAX_REDIRECTS: usize = 5;
29
30#[derive(Debug)]
32pub struct HttpFetcher<A: AuthProvider> {
33 client: reqwest::Client,
34 auth: A,
35 user_agent: CompactString,
36}
37
38pub(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 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 #[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 #[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
94trait 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
114async 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 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 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 let final_url = (resp.url() != &resource.url).then(|| resp.url().clone());
199 let classify_path = resp.url().path().to_owned();
200
201 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 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
241const RETRY_AFTER_MIN_MS: u64 = 1_000;
245const RETRY_AFTER_MAX_MS: u64 = 300_000;
246
247fn rate_limited(resp: &reqwest::Response) -> Error {
248 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 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}