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
38fn sniff_meta_charset(raw: &[u8]) -> Option<String> {
43 const WINDOW: usize = 2048;
44 let head = &raw[..raw.len().min(WINDOW)];
45 let text = String::from_utf8_lossy(head).to_ascii_lowercase();
46 let at = text.find("charset")? + "charset".len();
47 let rest = text[at..].trim_start().strip_prefix('=')?.trim_start();
48 let value: String = rest
49 .trim_start_matches(['"', '\''])
50 .chars()
51 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
52 .collect();
53 (!value.is_empty()).then_some(value)
54}
55
56enum Hop {
58 Page(FetchedPage),
59 Redirect(String),
60}
61
62fn build_client(
78 url: &reqwest::Url,
79 timeout: Duration,
80 pinned: &[SocketAddr],
81 tls: &TlsConfig,
82) -> anyhow::Result<Client> {
83 let mut builder = Client::builder()
84 .timeout(timeout)
85 .redirect(Policy::none())
86 .user_agent(USER_AGENT)
87 .gzip(true)
88 .brotli(true);
89
90 builder = tls.apply(builder)?;
93
94 if let Some(host) = url.host_str() {
95 if !pinned.is_empty() {
96 builder = builder.resolve_to_addrs(host, pinned);
97 }
98 }
99 Ok(builder.build()?)
100}
101
102async fn attempt(client: &Client, url: &str) -> Result<Hop, (anyhow::Error, bool)> {
105 let resp = match client
106 .get(url)
107 .header("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
108 .header("Accept-Language", "en-US,en;q=0.9")
109 .send()
110 .await
111 {
112 Ok(r) => r,
113 Err(e) => {
114 let transient = transient_send_error(&e);
115 return Err((e.into(), transient));
116 }
117 };
118
119 let status = resp.status();
120
121 if status.is_redirection() {
124 return match resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) {
125 Some(loc) => Ok(Hop::Redirect(loc.to_string())),
126 None => Err((
127 anyhow::anyhow!("redirect ({status}) without a Location header"),
128 false,
129 )),
130 };
131 }
132
133 let resp = match resp.error_for_status() {
134 Ok(r) => r,
135 Err(e) => {
136 let transient = transient_status(status);
137 return Err((e.into(), transient));
138 }
139 };
140
141 let final_url = resp.url().to_string();
142 let content_type = resp
143 .headers()
144 .get(CONTENT_TYPE)
145 .and_then(|v| v.to_str().ok())
146 .map(|s| s.to_string());
147
148 let raw = read_body_capped_bytes(resp).await?;
152 let declared = content_type
153 .as_deref()
154 .and_then(charset::from_content_type)
155 .or_else(|| sniff_meta_charset(&raw));
156 let (body, undecodable_charset) = charset::decode(&raw, declared.as_deref());
157
158 Ok(Hop::Page(FetchedPage {
159 body,
160 final_url,
161 content_type,
162 undecodable_charset,
163 }))
164}
165
166async fn fetch_with_retries(client: &Client, url: &str, deadline: Instant) -> anyhow::Result<Hop> {
169 let mut delay = Duration::from_millis(200);
170 for attempt_no in 1..=MAX_ATTEMPTS {
171 match attempt(client, url).await {
172 Ok(hop) => return Ok(hop),
173 Err((err, transient)) => {
174 if attempt_no == MAX_ATTEMPTS || !transient {
175 return Err(err);
176 }
177 if Instant::now() + delay >= deadline {
178 return Err(err);
179 }
180 tokio::time::sleep(delay).await;
181 delay *= 2;
182 }
183 }
184 }
185 unreachable!("loop returns on the final attempt")
186}
187
188pub async fn fetch_page(
195 url: &str,
196 timeout_secs: u64,
197 tls: &TlsConfig,
198) -> anyhow::Result<FetchedPage> {
199 let per_request = Duration::from_secs(timeout_secs);
200 let deadline = Instant::now() + per_request * TOTAL_BUDGET_MULTIPLIER;
201
202 let mut current = reqwest::Url::parse(url)?;
203 let mut hops = 0usize;
204
205 loop {
206 let remaining = deadline.saturating_duration_since(Instant::now());
207 if remaining.is_zero() {
208 anyhow::bail!(
209 "fetch exceeded its total budget ({}s across redirects and retries)",
210 timeout_secs * TOTAL_BUDGET_MULTIPLIER as u64
211 );
212 }
213
214 let pinned = guard::validate_url(¤t).await?;
217 let client = build_client(¤t, per_request.min(remaining), &pinned, tls)?;
218
219 match fetch_with_retries(&client, current.as_str(), deadline).await? {
220 Hop::Page(page) => return Ok(page),
221 Hop::Redirect(location) => {
222 hops += 1;
223 if hops > MAX_REDIRECTS {
224 anyhow::bail!("too many redirects (>{MAX_REDIRECTS})");
225 }
226 current = current
227 .join(&location)
228 .map_err(|e| anyhow::anyhow!("invalid redirect target `{location}`: {e}"))?;
229 }
230 }
231 }
232}