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