Skip to main content

ferrijs_fetch/
engine.rs

1//! The one send engine.
2//!
3//! A single manual-redirect loop over `reqwest` handles every request:
4//! `fetch` global, Playwright `request`, standalone or context-bound.
5//! reqwest is only the per-hop transport (`redirect::Policy::none()`);
6//! redirect following, cookie bridging, retries, the net-guard per-hop
7//! check, and the timeout budget all live here. Cookies come from the
8//! reqwest jar (standalone) or the browser context (bridged) — the only
9//! difference between the two.
10
11use std::sync::{Arc, Mutex};
12use std::time::Duration;
13
14use rustc_hash::FxHashMap;
15
16use super::body::{ByteStream, RequestPayload};
17use super::bridge::ContextBridge;
18use super::cookie::{cookie_matches_url, parse_set_cookie_headers};
19use super::error::FetchError;
20use super::headers::Headers;
21use super::model::{Credentials, RedirectMode, RemoteAddr, Request, Response, ResponseType};
22use super::net_guard::{GuardedResolver, NetGuard, check_url, preflight};
23
24/// reqwest client-cache key: `(ignore_https, dns-guard filter, attach jar)`.
25/// All clients share the pool's redirect policy (`none`); `attach jar`
26/// is `false` for a `credentials: omit` request so no cookies ride it.
27type ClientKey = (bool, Option<(bool, bool)>, bool);
28
29/// A cache of `reqwest::Client`s that differ only in TLS posture and the
30/// DNS-layer guard filter. Every client follows no redirects (the loop
31/// does) and shares the pool's cookie jar, if it has one.
32#[derive(Clone)]
33pub struct ClientPool {
34  base: reqwest::Client,
35  /// `Some` for a standalone client (reqwest owns the jar); `None` for a
36  /// context-bound client (the browser is the jar).
37  jar: Option<Arc<reqwest::cookie::Jar>>,
38  default_ignore_https: bool,
39  variants: Arc<Mutex<FxHashMap<ClientKey, reqwest::Client>>>,
40}
41
42impl ClientPool {
43  /// A standalone pool with its own reqwest cookie jar.
44  #[must_use]
45  pub fn standalone(ignore_https: bool) -> Self {
46    let jar = Arc::new(reqwest::cookie::Jar::default());
47    let base = build_client(Some(&jar), ignore_https, None);
48    Self {
49      base,
50      jar: Some(jar),
51      default_ignore_https: ignore_https,
52      variants: Arc::new(Mutex::new(FxHashMap::default())),
53    }
54  }
55
56  /// A context-bound pool: no jar (cookies live in the browser).
57  #[must_use]
58  pub fn bridged() -> Self {
59    Self {
60      base: build_client(None, false, None),
61      jar: None,
62      default_ignore_https: false,
63      variants: Arc::new(Mutex::new(FxHashMap::default())),
64    }
65  }
66
67  fn client(&self, ignore_https: bool, guard: Option<&NetGuard>, use_jar: bool) -> reqwest::Client {
68    let dns = guard.and_then(NetGuard::dns_filter);
69    // Attach the jar only when the request wants credentials AND the pool
70    // owns one (bridged pools have none — cookies ride the browser).
71    let attach_jar = use_jar && self.jar.is_some();
72    if ignore_https == self.default_ignore_https && dns.is_none() && attach_jar == self.jar.is_some() {
73      return self.base.clone();
74    }
75    let jar = attach_jar.then(|| self.jar.clone()).flatten();
76    let mut cache = self.variants.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
77    cache
78      .entry((ignore_https, dns, attach_jar))
79      .or_insert_with(|| build_client(jar.as_ref(), ignore_https, dns))
80      .clone()
81  }
82}
83
84/// Build a no-redirect reqwest client. `jar` is attached only for the
85/// standalone path; `dns` installs the SSRF address filter.
86fn build_client(
87  jar: Option<&Arc<reqwest::cookie::Jar>>,
88  ignore_https: bool,
89  dns: Option<(bool, bool)>,
90) -> reqwest::Client {
91  let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
92  if let Some(jar) = jar {
93    builder = builder.cookie_provider(jar.clone());
94  }
95  if ignore_https {
96    builder = builder.danger_accept_invalid_certs(true);
97  }
98  if let Some((block_metadata, block_private)) = dns {
99    builder = builder.dns_resolver(Arc::new(GuardedResolver {
100      block_metadata,
101      block_private,
102    }));
103  }
104  builder.build().unwrap_or_else(|e| {
105    // A default client would follow redirects, and the engine drives
106    // redirects itself — so the fallback keeps `Policy::none()` and the
107    // failure is logged rather than swallowed into different behaviour.
108    tracing::error!("failed to build the HTTP client ({e}); falling back to a default TLS posture");
109    reqwest::Client::builder()
110      .redirect(reqwest::redirect::Policy::none())
111      .build()
112      .unwrap_or_else(|_| reqwest::Client::new())
113  })
114}
115
116/// Whether an error message denotes a connection reset (the only class
117/// Playwright retries — `maxRetries`, ECONNRESET).
118fn is_reset_message(message: &str) -> bool {
119  let m = message.to_ascii_lowercase();
120  m.contains("connection reset") || m.contains("econnreset")
121}
122
123/// Whether a transport error is a connection reset. reqwest does not
124/// surface the errno directly, so the source chain is inspected for a
125/// `ConnectionReset` io error or a reset message.
126fn is_connection_reset(err: &reqwest::Error) -> bool {
127  let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
128  while let Some(e) = source {
129    if let Some(io) = e.downcast_ref::<std::io::Error>()
130      && io.kind() == std::io::ErrorKind::ConnectionReset
131    {
132      return true;
133    }
134    if is_reset_message(&e.to_string()) {
135      return true;
136    }
137    source = e.source();
138  }
139  false
140}
141
142/// Exponential backoff for retry attempt `n` (1-based): 250ms, 500ms,
143/// 1s, … — mirrors Playwright's `_sendRequestWithRetries` schedule.
144fn retry_backoff(attempt: u32) -> Duration {
145  Duration::from_millis(250u64.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1))))
146}
147
148/// The redirect budget for a request: `None` = never follow (`manual` /
149/// `error`, or `follow` with an explicit cap of 0); `Some(n)` = follow up
150/// to `n`; `follow` with no cap defaults to 20 (Playwright's default).
151fn follow_budget(redirect: RedirectMode, max_redirects: Option<u32>) -> Option<u32> {
152  match redirect {
153    RedirectMode::Manual | RedirectMode::Error => None,
154    RedirectMode::Follow => match max_redirects {
155      Some(0) => None,
156      Some(n) => Some(n),
157      None => Some(20),
158    },
159  }
160}
161
162/// The `Cookie` header for one hop.
163///
164/// `credentials: omit` sends none. The context-bound path assembles it
165/// from the browser jar, except on the first hop when the caller set one
166/// explicitly. The standalone path leaves the header alone (the pool's
167/// reqwest jar fills it in).
168async fn hop_headers(
169  bridge: Option<&Arc<dyn ContextBridge>>,
170  credentials: Credentials,
171  headers: &Headers,
172  request_url: &reqwest::Url,
173  keep_explicit_cookie: bool,
174) -> Result<Headers, FetchError> {
175  let mut hop = headers.clone();
176  if credentials == Credentials::Omit {
177    hop.remove("cookie");
178    return Ok(hop);
179  }
180  let Some(bridge) = bridge else { return Ok(hop) };
181  if keep_explicit_cookie {
182    return Ok(hop);
183  }
184  hop.remove("cookie");
185  let context_cookies = bridge.cookies().await.map_err(|e| FetchError::Network(e.to_string()))?;
186  let value = context_cookies
187    .iter()
188    .filter(|c| cookie_matches_url(c, request_url))
189    .map(|c| format!("{}={}", c.name, c.value))
190    .collect::<Vec<_>>()
191    .join("; ");
192  if !value.is_empty() {
193    hop.set("cookie", value);
194  }
195  Ok(hop)
196}
197
198/// One hop over the wire, retrying a connection reset up to
199/// `max_retries`.
200///
201/// `stream` is a single-use body: it is moved into the first attempt, so
202/// a reset cannot be re-tried once it has been taken (retries are
203/// skipped rather than silently re-sending an empty body).
204#[allow(clippy::too_many_arguments)]
205async fn send_hop(
206  client: &reqwest::Client,
207  method: &reqwest::Method,
208  request_url: &reqwest::Url,
209  headers: &Headers,
210  body: Option<&bytes::Bytes>,
211  mut stream: Option<ByteStream>,
212  deadline: tokio::time::Instant,
213  max_retries: u32,
214  timeout_message: &str,
215) -> Result<reqwest::Response, FetchError> {
216  let mut attempt = 0u32;
217  loop {
218    let timeout_left = deadline
219      .checked_duration_since(tokio::time::Instant::now())
220      .filter(|d| !d.is_zero())
221      .ok_or_else(|| FetchError::Timeout(timeout_message.to_string()))?;
222    let mut builder = client
223      .request(method.clone(), request_url.clone())
224      .timeout(timeout_left);
225    for (k, v) in headers.iter() {
226      builder = builder.header(k, v);
227    }
228    let streamed = stream.is_some();
229    if let Some(stream) = stream.take() {
230      builder = builder.body(reqwest::Body::wrap_stream(stream));
231    } else if let Some(bytes) = body {
232      builder = builder.body(bytes.clone());
233    }
234    match builder.send().await {
235      Ok(response) => return Ok(response),
236      Err(e) if !streamed && attempt < max_retries && is_connection_reset(&e) => {
237        attempt += 1;
238        tokio::time::sleep(retry_backoff(attempt)).await;
239      },
240      Err(e) => return Err(FetchError::Network(format!("request to {request_url} failed: {e}"))),
241    }
242  }
243}
244
245/// Write a hop's `Set-Cookie`s back into the browser context.
246///
247/// Playwright falls back to per-cookie adds when the batch fails
248/// (oversized values, or here: a context with no open page).
249async fn persist_set_cookies(
250  bridge: &Arc<dyn ContextBridge>,
251  request_url: &reqwest::Url,
252  response: &reqwest::Response,
253) {
254  let set_cookies = parse_set_cookie_headers(request_url, response.headers());
255  if set_cookies.is_empty() {
256    return;
257  }
258  let Err(batch_err) = bridge.add_cookies(set_cookies.clone()).await else {
259    return;
260  };
261  tracing::warn!("context-bound request: batch addCookies failed ({batch_err}), retrying individually");
262  for cookie in set_cookies {
263    let name = cookie.name.clone();
264    if let Err(e) = bridge.add_cookies(vec![cookie]).await {
265      tracing::warn!("context-bound request: dropping Set-Cookie {name:?}: {e}");
266    }
267  }
268}
269
270/// The absolute URL a 3xx points at, or `None` when it carries no
271/// `Location` (HTTP-redirect fetch step 4: return the response as-is).
272fn redirect_target(
273  request_url: &reqwest::Url,
274  response: &reqwest::Response,
275) -> Result<Option<reqwest::Url>, FetchError> {
276  let Some(location) = response
277    .headers()
278    .get(reqwest::header::LOCATION)
279    .and_then(|v| v.to_str().ok())
280  else {
281    return Ok(None);
282  };
283  request_url.join(location).map(Some).map_err(|_| {
284    FetchError::InvalidUrl(format!(
285      "uri requested responds with an invalid redirect URL: {location}"
286    ))
287  })
288}
289
290/// Apply HTTP-redirect fetch's request rewrites for one hop: 301/302
291/// POST and 303 non-GET/HEAD become body-less GETs, the `Cookie` header
292/// is always re-derived, and `Authorization` is dropped on a
293/// cross-origin hop (credentials are origin-scoped).
294fn rewrite_for_redirect(
295  status: u16,
296  request_url: &reqwest::Url,
297  next_url: &reqwest::Url,
298  method: &mut reqwest::Method,
299  body: &mut Option<bytes::Bytes>,
300  headers: &mut Headers,
301) {
302  let rewrite_to_get = ((status == 301 || status == 302) && *method == reqwest::Method::POST)
303    || (status == 303 && *method != reqwest::Method::GET && *method != reqwest::Method::HEAD);
304  if rewrite_to_get {
305    *method = reqwest::Method::GET;
306    *body = None;
307    for name in [
308      "content-encoding",
309      "content-language",
310      "content-length",
311      "content-location",
312      "content-type",
313    ] {
314      headers.remove(name);
315    }
316  }
317  headers.remove("cookie");
318  if next_url.origin() != request_url.origin() {
319    headers.remove("authorization");
320  }
321}
322
323/// Send a fully-resolved [`Request`] and return the [`Response`].
324///
325/// `bridge` present ⇒ the context-bound path (cookies read from / written
326/// back to the browser per hop). Absent ⇒ the standalone path (the pool's
327/// reqwest jar carries cookies).
328///
329/// # Errors
330///
331/// Returns a [`FetchError`] for a transport failure, an SSRF-guard
332/// denial, a redirect-budget overrun, a `redirect: error` 3xx, or a
333/// timeout.
334pub async fn send(
335  pool: &ClientPool,
336  bridge: Option<&Arc<dyn ContextBridge>>,
337  req: Request,
338) -> Result<Response, FetchError> {
339  let Request {
340    mut method,
341    url,
342    mut headers,
343    body,
344    redirect,
345    credentials,
346    max_redirects,
347    max_retries,
348    timeout,
349    ignore_https_errors,
350    net_guard,
351  } = req;
352
353  let method_str = method.to_string();
354  let resolved_url = url.to_string();
355  // A buffered body is replayed on every hop; a streamed one is moved
356  // into the first hop and cannot be replayed, so a redirect that would
357  // need to re-send it is a network error (WHATWG: a request whose body
358  // is a `ReadableStream` cannot be redirected).
359  let (mut body, mut pending_stream) = match body.into_request_payload() {
360    RequestPayload::Empty => (None, None),
361    RequestPayload::Bytes(b) => (Some(b), None),
362    RequestPayload::Stream(s) => (None, Some(s)),
363    RequestPayload::Invalid => {
364      return Err(FetchError::Body(
365        "a response body cannot be sent as a request body".to_string(),
366      ));
367    },
368  };
369  let body_is_streamed = pending_stream.is_some();
370
371  let guard = net_guard.as_ref().filter(|g| g.is_active());
372  if let Some(g) = guard {
373    preflight(&resolved_url, g).map_err(FetchError::from)?;
374  }
375  // `credentials: omit` rides a jar-less client so no stored cookie is
376  // sent and no `Set-Cookie` is stored.
377  let client = pool.client(ignore_https_errors, guard, credentials != Credentials::Omit);
378
379  let explicit_cookie_header = headers.contains("cookie");
380  let mut remaining = follow_budget(redirect, max_redirects);
381  let deadline = tokio::time::Instant::now() + timeout;
382  let mut request_url = url;
383  let mut first_hop = true;
384  let mut hops_followed = 0u32;
385
386  let response = 'redirects: loop {
387    if let Some(g) = guard {
388      check_url(&request_url, g).map_err(FetchError::from)?;
389    }
390
391    let hop = hop_headers(
392      bridge,
393      credentials,
394      &headers,
395      &request_url,
396      first_hop && explicit_cookie_header,
397    )
398    .await?;
399
400    let response = send_hop(
401      &client,
402      &method,
403      &request_url,
404      &hop,
405      body.as_ref(),
406      pending_stream.take(),
407      deadline,
408      max_retries,
409      &format!("{method_str} {resolved_url} timed out"),
410    )
411    .await?;
412
413    if let Some(bridge) = bridge {
414      persist_set_cookies(bridge, &request_url, &response).await;
415    }
416
417    let status = response.status().as_u16();
418    if matches!(status, 301 | 302 | 303 | 307 | 308)
419      && let Some(budget) = remaining
420    {
421      if budget == 0 {
422        return Err(FetchError::TooManyRedirects(
423          follow_budget(redirect, max_redirects).unwrap_or(0),
424        ));
425      }
426      // HTTP-redirect fetch step 4: no Location = return the response.
427      let Some(next_url) = redirect_target(&request_url, &response)? else {
428        break 'redirects response;
429      };
430      // A streamed body was consumed by the hop just sent. Following the
431      // redirect would re-send the request with NO body, which the
432      // server would silently accept as an empty payload — so refuse
433      // instead. 303 is exempt: it rewrites to a body-less GET anyway.
434      if body_is_streamed && status != 303 {
435        return Err(FetchError::RedirectRefused(format!(
436          "{method_str} {resolved_url}: cannot follow a redirect for a request with a streaming body"
437        )));
438      }
439      rewrite_for_redirect(status, &request_url, &next_url, &mut method, &mut body, &mut headers);
440      request_url = next_url;
441      remaining = Some(budget - 1);
442      first_hop = false;
443      hops_followed += 1;
444      continue;
445    }
446
447    break 'redirects response;
448  };
449
450  if redirect == RedirectMode::Error && response.status().is_redirection() {
451    return Err(FetchError::RedirectRefused(format!(
452      "{method_str} {resolved_url}: unexpected redirect (redirect: \"error\")"
453    )));
454  }
455  Ok(into_response(response, &request_url, redirect, hops_followed))
456}
457
458/// Turn the final reqwest response into the typed [`Response`], applying
459/// the WHATWG response-type filter (`manual` + 3xx = opaque redirect).
460fn into_response(
461  response: reqwest::Response,
462  request_url: &reqwest::Url,
463  redirect: RedirectMode,
464  hops_followed: u32,
465) -> Response {
466  let status = response.status().as_u16();
467  let status_text = response.status().canonical_reason().unwrap_or("Unknown").to_string();
468  let server_addr = response.remote_addr().map(|addr| RemoteAddr {
469    ip_address: addr.ip().to_string(),
470    port: addr.port(),
471  });
472  let headers: Headers = response
473    .headers()
474    .iter()
475    .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
476    .collect::<Vec<_>>()
477    .into();
478  let unfollowed_redirect = redirect == RedirectMode::Manual && response.status().is_redirection();
479  Response {
480    status,
481    status_text,
482    url: request_url.to_string(),
483    headers,
484    body: super::body::Body::from_response(response),
485    redirected: hops_followed > 0,
486    unfollowed_redirect,
487    server_addr,
488    type_: if unfollowed_redirect {
489      ResponseType::OpaqueRedirect
490    } else {
491      ResponseType::Basic
492    },
493  }
494}
495
496#[cfg(test)]
497mod tests {
498  use super::*;
499
500  #[test]
501  fn reset_message_detection() {
502    assert!(is_reset_message(
503      "error sending request: Connection reset by peer (os error 54)"
504    ));
505    assert!(is_reset_message("ECONNRESET"));
506    assert!(!is_reset_message("connection closed before message completed"));
507    assert!(!is_reset_message("404 Not Found"));
508  }
509
510  #[test]
511  fn retry_backoff_is_exponential() {
512    assert_eq!(retry_backoff(1), Duration::from_millis(250));
513    assert_eq!(retry_backoff(2), Duration::from_millis(500));
514    assert_eq!(retry_backoff(3), Duration::from_secs(1));
515  }
516
517  #[test]
518  fn follow_budget_maps_modes() {
519    assert_eq!(follow_budget(RedirectMode::Follow, None), Some(20));
520    assert_eq!(follow_budget(RedirectMode::Follow, Some(0)), None);
521    assert_eq!(follow_budget(RedirectMode::Follow, Some(3)), Some(3));
522    assert_eq!(follow_budget(RedirectMode::Manual, None), None);
523    assert_eq!(follow_budget(RedirectMode::Error, Some(5)), None);
524  }
525}