1use 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
24type ClientKey = (bool, Option<(bool, bool)>, bool);
28
29#[derive(Clone)]
33pub struct ClientPool {
34 base: reqwest::Client,
35 jar: Option<Arc<reqwest::cookie::Jar>>,
38 default_ignore_https: bool,
39 variants: Arc<Mutex<FxHashMap<ClientKey, reqwest::Client>>>,
40}
41
42impl ClientPool {
43 #[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 #[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 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
84fn 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 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
116fn is_reset_message(message: &str) -> bool {
119 let m = message.to_ascii_lowercase();
120 m.contains("connection reset") || m.contains("econnreset")
121}
122
123fn 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
142fn retry_backoff(attempt: u32) -> Duration {
145 Duration::from_millis(250u64.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1))))
146}
147
148fn 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
162async 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#[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
245async 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
270fn 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
290fn 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
323pub 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 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 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 let Some(next_url) = redirect_target(&request_url, &response)? else {
428 break 'redirects response;
429 };
430 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
458fn 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}