1use reqwest::header::{CONTENT_TYPE, LOCATION};
2use reqwest::{redirect::Policy, Client};
3use std::net::SocketAddr;
4use std::time::{Duration, Instant};
5
6use crate::guard;
7use crate::tls::TlsConfig;
8use webfetch_core::charset;
9use webfetch_core::http::{
10 read_body_capped_bytes, transient_send_error, transient_status, USER_AGENT,
11};
12
13const MAX_ATTEMPTS: u32 = 3;
14const MAX_REDIRECTS: usize = 5;
15
16const TOTAL_BUDGET_MULTIPLIER: u32 = 3;
25
26#[derive(Debug, Clone)]
29pub struct FetchedPage {
30 pub body: String,
31 pub final_url: String,
32 pub content_type: Option<String>,
33 pub undecodable_charset: Option<String>,
36}
37
38enum Hop {
40 Page(FetchedPage),
41 Redirect(String),
42}
43
44fn build_client(
60 url: &reqwest::Url,
61 timeout: Duration,
62 pinned: &[SocketAddr],
63 tls: &TlsConfig,
64) -> anyhow::Result<Client> {
65 let mut builder = Client::builder()
66 .timeout(timeout)
67 .redirect(Policy::none())
68 .user_agent(USER_AGENT)
69 .gzip(true)
70 .brotli(true);
71
72 builder = tls.apply(builder)?;
75
76 if let Some(host) = url.host_str() {
77 if !pinned.is_empty() {
78 builder = builder.resolve_to_addrs(host, pinned);
79 }
80 }
81 Ok(builder.build()?)
82}
83
84async fn attempt(client: &Client, url: &str) -> Result<Hop, (anyhow::Error, bool)> {
87 let resp = match client
88 .get(url)
89 .header("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
90 .header("Accept-Language", "en-US,en;q=0.9")
91 .send()
92 .await
93 {
94 Ok(r) => r,
95 Err(e) => {
96 let transient = transient_send_error(&e);
97 return Err((e.into(), transient));
98 }
99 };
100
101 let status = resp.status();
102
103 if status.is_redirection() {
106 return match resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) {
107 Some(loc) => Ok(Hop::Redirect(loc.to_string())),
108 None => Err((
109 anyhow::anyhow!("redirect ({status}) without a Location header"),
110 false,
111 )),
112 };
113 }
114
115 let resp = match resp.error_for_status() {
116 Ok(r) => r,
117 Err(e) => {
118 let transient = transient_status(status);
119 return Err((e.into(), transient));
120 }
121 };
122
123 let final_url = resp.url().to_string();
124 let content_type = resp
125 .headers()
126 .get(CONTENT_TYPE)
127 .and_then(|v| v.to_str().ok())
128 .map(|s| s.to_string());
129
130 let raw = read_body_capped_bytes(resp).await?;
134 let declared = content_type
135 .as_deref()
136 .and_then(charset::from_content_type)
137 .or_else(|| charset::sniff_meta(&raw));
138 let (body, undecodable_charset) = charset::decode(&raw, declared.as_deref());
139
140 Ok(Hop::Page(FetchedPage {
141 body,
142 final_url,
143 content_type,
144 undecodable_charset,
145 }))
146}
147
148async fn fetch_with_retries(client: &Client, url: &str, deadline: Instant) -> anyhow::Result<Hop> {
151 let mut delay = Duration::from_millis(200);
152 for attempt_no in 1..=MAX_ATTEMPTS {
153 match attempt(client, url).await {
154 Ok(hop) => return Ok(hop),
155 Err((err, transient)) => {
156 if attempt_no == MAX_ATTEMPTS || !transient {
157 return Err(err);
158 }
159 if Instant::now() + delay >= deadline {
160 return Err(err);
161 }
162 tokio::time::sleep(delay).await;
163 delay *= 2;
164 }
165 }
166 }
167 unreachable!("loop returns on the final attempt")
168}
169
170pub async fn fetch_page(
177 url: &str,
178 timeout_secs: u64,
179 tls: &TlsConfig,
180) -> anyhow::Result<FetchedPage> {
181 let per_request = Duration::from_secs(timeout_secs);
182 let deadline = Instant::now() + per_request * TOTAL_BUDGET_MULTIPLIER;
183
184 let mut current = reqwest::Url::parse(url)?;
185 let mut hops = 0usize;
186
187 loop {
188 let remaining = deadline.saturating_duration_since(Instant::now());
189 if remaining.is_zero() {
190 anyhow::bail!(
191 "fetch exceeded its total budget ({}s across redirects and retries)",
192 timeout_secs * TOTAL_BUDGET_MULTIPLIER as u64
193 );
194 }
195
196 let pinned = guard::validate_url(¤t).await?;
199 let client = build_client(¤t, per_request.min(remaining), &pinned, tls)?;
200
201 match fetch_with_retries(&client, current.as_str(), deadline).await? {
202 Hop::Page(page) => return Ok(page),
203 Hop::Redirect(location) => {
204 hops += 1;
205 if hops > MAX_REDIRECTS {
206 anyhow::bail!("too many redirects (>{MAX_REDIRECTS})");
207 }
208 current = current
209 .join(&location)
210 .map_err(|e| anyhow::anyhow!("invalid redirect target `{location}`: {e}"))?;
211 }
212 }
213 }
214}