fast_down_api/core/
prefetch.rs1use crate::{Config, Event, PlanError, Tx, utils::build_header};
2use fast_down::{UrlInfo, fast_puller::build_client, http::Prefetch};
3use reqwest::Response;
4use url::Url;
5
6#[allow(clippy::result_large_err)]
22pub async fn prefetch(
23 url: &Url,
24 config: &Config,
25 tx: &Tx,
26) -> Result<(UrlInfo, Response), PlanError> {
27 let client = build_client(
28 build_header(&config.headers),
29 config.proxy.as_deref(),
30 config.accept_invalid_certs,
31 config.accept_invalid_hostnames,
32 config.cookie_store,
33 config.local_address.first().copied(),
34 config.max_redirects,
35 )
36 .map_err(PlanError::BuildClient)?;
37 let mut retry_count = 0;
38 loop {
39 match client.prefetch(url.clone()).await {
40 Ok(t) => {
41 let _ = tx.send(Event::Prefetch(t.0.clone()));
42 break Ok(t);
43 }
44 Err((e, t)) => {
45 retry_count += 1;
46 if retry_count >= config.retry_times {
47 return Err(PlanError::Prefetch(e));
48 }
49 let _ = tx.send(Event::PrefetchError(e));
50 tokio::time::sleep(t.unwrap_or(config.retry_gap)).await;
51 }
52 }
53 }
54}
55
56#[cfg(test)]
57mod tests {
58 #![allow(clippy::unwrap_used)]
59 use super::*;
60 use crate::create_channel;
61 use std::time::Duration;
62
63 use bytes::Bytes;
64 use http_body_util::Full;
65 use hyper::body::Incoming;
66 use hyper::header::{
67 ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, LAST_MODIFIED, RANGE,
68 };
69 use hyper::server::conn::http1;
70 use hyper::service::service_fn;
71 use hyper::{Request, Response, StatusCode};
72 use hyper_util::rt::TokioIo;
73 use std::convert::Infallible;
74 use std::sync::Arc;
75
76 #[tokio::test]
77 async fn prefetch_gives_up_after_retries_on_unreachable() {
78 let url = Url::parse("http://127.0.0.1:1/never").unwrap();
82 let config = Config {
83 retry_times: 2,
84 retry_gap: Duration::ZERO,
85 ..Default::default()
86 };
87 let (tx, rx) = create_channel();
88 let result = prefetch(&url, &config, &tx).await;
89 assert!(
90 matches!(result, Err(PlanError::Prefetch(_))),
91 "prefetch must return the last error after exhausting retries"
92 );
93 drop(tx);
94 let mut errors = 0;
95 while let Ok(e) = rx.recv().await {
96 if matches!(e, Event::PrefetchError(_)) {
97 errors += 1;
98 }
99 }
100 assert_eq!(
101 errors, 1,
102 "2 attempts means 1 retryable failure is reported as an event and 1 is returned"
103 );
104 }
105
106 #[tokio::test]
107 #[allow(clippy::too_many_lines)]
108 async fn prefetch_succeeds_against_local_server() {
109 let body: Arc<Vec<u8>> = Arc::new(vec![0xABu8; 1024]);
115 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
116 let addr = listener.local_addr().unwrap();
117
118 tokio::spawn(async move {
119 loop {
120 let (stream, _) = listener.accept().await.unwrap();
121 let stream = stream;
122 let body = body.clone();
123 let io = TokioIo::new(stream);
124 tokio::spawn(async move {
125 let service = service_fn(move |req: Request<Incoming>| {
126 let body = body.clone();
127 async move {
128 let total = body.len();
129 let is_range = req.headers().contains_key(RANGE);
130 let body_bytes: Bytes = if is_range {
131 Bytes::from(vec![0xABu8])
132 } else {
133 Bytes::from(body.to_vec())
134 };
135 let builder = Response::builder()
136 .status(if is_range {
137 StatusCode::PARTIAL_CONTENT
138 } else {
139 StatusCode::OK
140 })
141 .header(
142 CONTENT_LENGTH,
143 if is_range {
144 "1".to_string()
145 } else {
146 total.to_string()
147 },
148 )
149 .header(ACCEPT_RANGES, "bytes")
150 .header(ETAG, "etag-test")
151 .header(LAST_MODIFIED, "Wed, 21 Oct 2026 07:28:00 GMT")
152 .header(CONTENT_TYPE, "application/octet-stream");
153 let builder = if is_range {
154 builder.header(CONTENT_RANGE, format!("bytes 0-0/{total}"))
155 } else {
156 builder
157 };
158 let resp = builder.body(Full::new(body_bytes)).unwrap();
159 Ok::<_, Infallible>(resp)
160 }
161 });
162 let _ = http1::Builder::new().serve_connection(io, service).await;
163 });
164 }
165 });
166
167 let url = Url::parse(&format!("http://{addr}/file.bin")).unwrap();
168 let config = Config::default();
169 let (tx, rx) = create_channel();
170 let result = prefetch(&url, &config, &tx).await;
171
172 assert!(
173 result.is_ok(),
174 "prefetch must succeed against a well-behaved server"
175 );
176 let (info, _resp) = result.unwrap();
177 assert_eq!(info.size, 1024, "size must come from content-length");
178 assert_eq!(
179 info.file_id.etag.as_deref(),
180 Some("etag-test"),
181 "etag must be read from the response"
182 );
183 assert!(
184 info.supports_range,
185 "a 206 content-range response must advertise range support"
186 );
187 assert!(
188 info.fast_download,
189 "size > 0 with range support must enable fast_download"
190 );
191
192 drop(tx);
193 let mut saw_prefetch = false;
194 while let Ok(e) = rx.recv().await {
195 if matches!(e, Event::Prefetch(_)) {
196 saw_prefetch = true;
197 }
198 }
199 assert!(
200 saw_prefetch,
201 "prefetch must emit Event::Prefetch on the success path"
202 );
203 }
204}