Skip to main content

turbo_tasks_fetch/
client.rs

1use std::{
2    cmp::max,
3    fmt::{Display, Formatter},
4    hash::Hash,
5    sync::LazyLock,
6    time::{Duration, SystemTime},
7};
8
9use anyhow::Result;
10use quick_cache::sync::Cache;
11use turbo_rcstr::RcStr;
12use turbo_tasks::{
13    Completion, FxIndexSet, InvalidationReason, InvalidationReasonKind, Invalidator, ReadRef,
14    ResolvedVc, Vc, duration_span, util::StaticOrArc,
15};
16
17// Route every `reqwest::…` path in this module to the local stand-in on wasm.
18#[cfg(target_family = "wasm")]
19use crate::wasm_reqwest as reqwest;
20use crate::{FetchError, FetchResult, HttpResponse, HttpResponseBody};
21
22const MAX_CLIENTS: usize = 16;
23static CLIENT_CACHE: LazyLock<Cache<ReadRef<FetchClientConfig>, reqwest::Client>> =
24    LazyLock::new(|| Cache::new(MAX_CLIENTS));
25
26/// Represents the configuration needed to construct a [`reqwest::Client`].
27///
28/// This is used to cache clients keyed by their configuration, so the configuration should contain
29/// as few fields as possible and change infrequently.
30///
31/// This is needed because [`reqwest::ClientBuilder`] does not implement the required traits. This
32/// factory cannot be a closure because closures do not implement `Eq` or `Hash`.
33#[turbo_tasks::value(shared)]
34#[derive(Hash)]
35pub struct FetchClientConfig {
36    /// Minimum cache TTL in seconds. Responses with a `Cache-Control: max-age` shorter than this
37    /// will be clamped to this value. This prevents pathologically short timeouts from causing an
38    /// invalidation bomb. Defaults to 1 hour.
39    pub min_cache_control: Duration,
40    /// Maximum time to establish a connection (DNS + TCP + TLS). Defaults to 10 seconds.
41    pub connect_timeout: Duration,
42    /// Maximum time for the entire request. Always larger than `connect_timeout`. Defaults to
43    /// 60 seconds.
44    pub timeout: Duration,
45    /// Times to retry a transient failure (connection error, timeout, or 5xx) before surfacing
46    /// it. Total attempts is `max_retries + 1`. Defaults to 0.
47    pub max_retries: u32,
48}
49
50impl Default for FetchClientConfig {
51    fn default() -> Self {
52        Self {
53            min_cache_control: Duration::from_secs(60 * 60),
54            connect_timeout: Duration::from_secs(10),
55            timeout: Duration::from_secs(60),
56            max_retries: 0,
57        }
58    }
59}
60
61impl FetchClientConfig {
62    /// Returns a cached instance of `reqwest::Client` it exists, otherwise constructs a new one.
63    ///
64    /// The cache is bound in size to prevent accidental blowups or leaks. However, in practice,
65    /// very few clients should be created, likely only when the bundler configuration changes.
66    ///
67    /// Client construction is largely deterministic, aside from changes to system TLS
68    /// configuration.
69    ///
70    /// The reqwest client fails to construct if the TLS backend cannot be initialized, or the
71    /// resolver cannot load the system configuration. These failures should be treated as
72    /// cached for some amount of time, but ultimately transient (e.g. using
73    /// `#[turbo_tasks::function(session_dependent)]`).
74    pub fn try_get_cached_reqwest_client(
75        self: ReadRef<FetchClientConfig>,
76    ) -> reqwest::Result<reqwest::Client> {
77        CLIENT_CACHE.get_or_insert_with(&self, {
78            let this = ReadRef::clone(&self);
79            move || this.try_build_uncached_reqwest_client()
80        })
81    }
82
83    fn try_build_uncached_reqwest_client(&self) -> reqwest::Result<reqwest::Client> {
84        #[allow(unused_mut)]
85        let mut builder = reqwest::Client::builder()
86            .connect_timeout(self.connect_timeout)
87            .timeout(self.timeout);
88        #[cfg(any(target_os = "linux", all(windows, not(target_arch = "aarch64"))))]
89        {
90            use std::sync::Once;
91            static ONCE: Once = Once::new();
92            ONCE.call_once(|| {
93                rustls::crypto::ring::default_provider()
94                    .install_default()
95                    .unwrap()
96            });
97            builder = builder.tls_backend_rustls();
98        }
99        #[cfg(all(windows, target_arch = "aarch64"))]
100        {
101            builder = builder.tls_backend_native();
102        }
103        #[cfg(target_os = "linux")]
104        {
105            // Add webpki_root_certs on Linux (in addition to reqwest's default
106            // `rustls-platform-verifier`), in case the user is building in a bare-bones docker
107            // image that does not contain any root certs (e.g. `oven/bun:slim`).
108            builder = builder.tls_certs_merge(webpki_root_certs::TLS_SERVER_ROOT_CERTS.iter().map(
109                |der| {
110                    reqwest::Certificate::from_der(der)
111                        .expect("webpki_root_certs should parse correctly")
112                },
113            ))
114        }
115        builder.build()
116    }
117}
118
119/// Invalidation was caused by a max-age deadline returned by a server
120#[derive(PartialEq, Eq, Hash)]
121pub(crate) struct HttpTimeout;
122
123impl InvalidationReason for HttpTimeout {
124    fn kind(&self) -> Option<StaticOrArc<dyn InvalidationReasonKind>> {
125        Some(StaticOrArc::Static(&HTTP_TIMEOUT_KIND))
126    }
127}
128
129impl Display for HttpTimeout {
130    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
131        write!(f, "http max-age timeout")
132    }
133}
134
135/// Invalidation kind for [HttpTimeout]
136#[derive(PartialEq, Eq, Hash)]
137struct HttpTimeoutKind;
138
139static HTTP_TIMEOUT_KIND: HttpTimeoutKind = HttpTimeoutKind;
140
141impl InvalidationReasonKind for HttpTimeoutKind {
142    fn fmt(
143        &self,
144        reasons: &FxIndexSet<StaticOrArc<dyn InvalidationReason>>,
145        f: &mut Formatter<'_>,
146    ) -> std::fmt::Result {
147        write!(f, "{} fetches timed out", reasons.len())
148    }
149}
150
151/// Internal result from `fetch_inner` that includes the invalidator for TTL-based re-fetching.
152#[turbo_tasks::value(shared)]
153struct FetchInnerResult {
154    result: ResolvedVc<FetchResult>,
155    /// Invalidator for the `fetch_inner` task. Used by the outer `fetch` to set up a timer that
156    /// triggers re-fetching when the Cache-Control max-age expires.
157    invalidator: Option<Invalidator>,
158    /// Absolute deadline (seconds since UNIX epoch) after which the cached response should be
159    /// re-fetched. Computed as `now + max-age` at fetch time. An absolute timestamp is used
160    /// instead of a relative duration so that the remaining TTL is correct on warm cache restore.
161    deadline_secs: Option<u64>,
162}
163
164#[turbo_tasks::value_impl]
165impl FetchClientConfig {
166    /// Performs the actual HTTP request. This task is `network` but NOT `session_dependent`, so
167    /// its cached result survives restarts. The outer `fetch` task (which IS `session_dependent`)
168    /// reads the cached invalidator and sets up a timer for TTL-based re-fetching.
169    #[turbo_tasks::function(network)]
170    async fn fetch_inner(
171        self: Vc<FetchClientConfig>,
172        url: RcStr,
173        user_agent: Option<RcStr>,
174    ) -> Result<Vc<FetchInnerResult>> {
175        let url_ref = &*url;
176        let this = self.await?;
177        let min_cache_control_secs = this.min_cache_control;
178        let max_retries = this.max_retries;
179        let response_result: reqwest::Result<(HttpResponse, Option<u64>)> = async move {
180            let reqwest_client = this.try_get_cached_reqwest_client()?;
181
182            let mut builder = reqwest_client.get(url_ref);
183            if let Some(user_agent) = user_agent {
184                builder = builder.header("User-Agent", user_agent.as_str());
185            }
186
187            let response = {
188                let _span = duration_span!("fetch request", blocking = true, url = url_ref);
189                let mut attempt = 0;
190                loop {
191                    let request = builder.try_clone().expect("request should be cloneable");
192                    let result = {
193                        let _span = duration_span!(
194                            "fetch attempt",
195                            blocking = true,
196                            url = url_ref,
197                            attempt
198                        );
199                        request.send().await.and_then(|r| r.error_for_status())
200                    };
201                    match result {
202                        Ok(response) => break response,
203                        Err(err)
204                            if attempt < max_retries
205                                && (err.is_connect()
206                                    || err.is_timeout()
207                                    || err.is_request()
208                                    || err.status().is_some_and(|s| s.is_server_error())) =>
209                        {
210                            attempt += 1;
211                        }
212                        Err(err) => return Err(err),
213                    }
214                }
215            };
216
217            let status = response.status().as_u16();
218            let max_age = parse_cache_control(response.headers());
219
220            let body = {
221                let _span = duration_span!("fetch response", blocking = true, url = url_ref);
222                response.bytes().await?
223            }
224            .to_vec();
225
226            Ok((
227                HttpResponse {
228                    status,
229                    body: HttpResponseBody(body).resolved_cell(),
230                },
231                max_age,
232            ))
233        }
234        .await;
235
236        match response_result {
237            Ok((resp, max_age_secs)) => {
238                if let Some(max_age_secs) = max_age_secs {
239                    let max_age_secs = max(max_age_secs, min_cache_control_secs.as_secs());
240                    // Transform the relative offset to an absolute deadline so it can be
241                    // cached.
242                    let deadline_secs = SystemTime::now()
243                        .duration_since(SystemTime::UNIX_EPOCH)
244                        // If the system clock is borked, just don't respect deadlines
245                        .ok()
246                        .map(|d| d.as_secs() + max_age_secs);
247                    let invalidator = turbo_tasks::get_invalidator();
248                    Ok(FetchInnerResult {
249                        result: ResolvedVc::cell(Ok(resp.resolved_cell())),
250                        invalidator,
251                        deadline_secs,
252                    }
253                    .cell())
254                } else {
255                    Completion::session_dependent().await?;
256                    Ok(FetchInnerResult {
257                        result: ResolvedVc::cell(Ok(resp.resolved_cell())),
258                        invalidator: None,
259                        deadline_secs: None,
260                    }
261                    .cell())
262                }
263            }
264            Err(err) => {
265                // Read session_dependent_completion so that this task is re-dirtied on session
266                // restore. This ensures transient errors (network down, DNS failure) are retried
267                // on the next session without a timer or busy-loop.
268                Completion::session_dependent().await?;
269                Ok(FetchInnerResult {
270                    result: ResolvedVc::cell(Err(
271                        FetchError::from_reqwest_error(&err, &url).resolved_cell()
272                    )),
273                    invalidator: None,
274                    deadline_secs: None,
275                }
276                .cell())
277            }
278        }
279    }
280
281    /// Fetches the given URL and returns the response. Results are cached across sessions using
282    /// TTL from the response's `Cache-Control: max-age` header.
283    ///
284    /// This is the outer task in a two-task pattern:
285    /// - `fetch` (session_dependent): always re-executes on restore, reads the cached inner result,
286    ///   and spawns a timer for mid-session TTL expiry.
287    /// - `fetch_inner` (network, NOT session_dependent): performs the actual HTTP request and stays
288    ///   cached across restarts. Returns an `Invalidator` that the outer task uses to trigger
289    ///   re-fetching when the TTL expires.
290    #[turbo_tasks::function(network, session_dependent)]
291    pub async fn fetch(
292        self: Vc<FetchClientConfig>,
293        url: RcStr,
294        user_agent: Option<RcStr>,
295    ) -> Result<Vc<FetchResult>> {
296        let FetchInnerResult {
297            result,
298            deadline_secs,
299            invalidator,
300        } = *self.fetch_inner(url, user_agent).await?;
301
302        // Set up a timer to invalidate fetch_inner when the TTL expires.
303        // On warm cache restore, this re-executes (session_dependent), reads the persisted
304        // deadline from fetch_inner's cached result, and starts a timer for the remaining time.
305        //
306        // Skip when dependency tracking is disabled (e.g. one-shot `next build`) since
307        // invalidation panics without dependency tracking and the timer would be wasted work.
308        if turbo_tasks::turbo_tasks().is_tracking_dependencies()
309            && let (Some(deadline_secs), Some(invalidator)) = (deadline_secs, invalidator)
310        {
311            // transform absolute deadline back to a relative duration for the sleep call
312            // IF the system clock is broken, just don't bother.
313            if let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
314                let remaining = Duration::from_secs(deadline_secs.saturating_sub(now.as_secs()));
315                // NOTE: in the case where the deadline is expired on session start this timeout
316                // will immediately invalidate and race with us returning.  This is
317                // basically fine since in the most common case the actual fetch
318                // result is identical so this gives us a kind of 'stale while
319                // revalidate' feature. alternatively we could synchronously
320                // invalidate and re-execute `fetch-inner` but that simply adds
321                // latency in the common case where our fetch is identical. NOTE(2):
322                // if for some reason `fetch` is re-executed but `fetch-inner` isn't we could
323                // end up with multiple timers.  Currently there is no known case where this could
324                // happen, if it somehow does we could end up with redundant invalidations and
325                // re-fetches.  The solution is to detect this with a mutable hash map on
326                // FetchClientConfig to track outstanding timers and cancel them.
327                turbo_tasks::spawn(async move {
328                    tokio::time::sleep(remaining).await;
329                    invalidator
330                        .invalidate_with_reason(&*turbo_tasks::turbo_tasks(), HttpTimeout {});
331                });
332            }
333        }
334
335        Ok(*result)
336    }
337}
338
339/// Parses the `max-age` directive from a `Cache-Control` header value.
340/// Returns the max-age in seconds, or `None` if not present or unparseable.
341/// None means we shouldn't cache longer than the current session
342fn parse_cache_control(headers: &reqwest::header::HeaderMap) -> Option<u64> {
343    let value = headers.get(reqwest::header::CACHE_CONTROL)?.to_str().ok()?;
344    let mut max_age = None;
345    for directive in value.split(',') {
346        let (key, val) = {
347            if let Some(index) = directive.find('=') {
348                (directive[0..index].trim(), Some(&directive[index + 1..]))
349            } else {
350                (directive.trim(), None)
351            }
352        };
353        if key.eq_ignore_ascii_case("max-age")
354            && let Some(val) = val
355        {
356            max_age = val.trim().parse().ok();
357        } else if key.eq_ignore_ascii_case("no-cache") || key.eq_ignore_ascii_case("no-store") {
358            return None;
359        }
360    }
361    max_age
362}
363
364#[doc(hidden)]
365pub fn __test_only_reqwest_client_cache_clear() {
366    CLIENT_CACHE.clear()
367}
368
369#[doc(hidden)]
370pub fn __test_only_reqwest_client_cache_len() -> usize {
371    CLIENT_CACHE.len()
372}
373
374#[cfg(test)]
375mod tests {
376    use reqwest::header::{CACHE_CONTROL, HeaderMap, HeaderValue};
377
378    use super::parse_cache_control;
379
380    fn headers(value: &str) -> HeaderMap {
381        let mut h = HeaderMap::new();
382        h.insert(CACHE_CONTROL, HeaderValue::from_str(value).unwrap());
383        h
384    }
385
386    #[test]
387    fn max_age() {
388        assert_eq!(parse_cache_control(&headers("max-age=300")), Some(300));
389        assert_eq!(parse_cache_control(&headers("MAX-AGE = 300")), Some(300));
390        assert_eq!(
391            parse_cache_control(&headers("public, max-age=3600, must-revalidate")),
392            Some(3600)
393        );
394    }
395
396    #[test]
397    fn no_cache_headers() {
398        assert_eq!(parse_cache_control(&headers("NO-CACHE")), None);
399        assert_eq!(parse_cache_control(&headers("no-cache")), None);
400        assert_eq!(parse_cache_control(&headers("no-store")), None);
401        assert_eq!(parse_cache_control(&headers("max-age=300, no-store")), None);
402        assert_eq!(parse_cache_control(&HeaderMap::new()), None);
403    }
404}