reqwest_rotate/client.rs
1//! The main [`RotatingClient`] and its builder.
2
3use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
8use reqwest::{Request, Response};
9
10use crate::error::Error;
11use crate::proxy::ProxyList;
12use crate::rate_limit::RateLimiter;
13#[cfg(feature = "tracing")]
14use crate::retry::error_kind;
15use crate::retry::{
16 backoff_delay, is_idempotent, is_proxy_failure_status, is_retryable_status, is_transport_error,
17 retry_after, should_retry_error,
18};
19use crate::trace_log;
20
21const DEFAULT_RETRIES: u32 = 3;
22const DEFAULT_BACKOFF_BASE: Duration = Duration::from_millis(200);
23const DEFAULT_BACKOFF_MAX: Duration = Duration::from_secs(30);
24const DEFAULT_MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
25const DEFAULT_PROXY_COOLDOWN: Duration = Duration::from_secs(60);
26const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
27const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
28
29/// Roughly how much of a retryable response's body is read before the
30/// retry, so the connection can go back to the pool. Reading stops after
31/// the chunk that crosses this budget, so the actual count can run a
32/// little over. A body that does not end within the budget is dropped
33/// along with its connection: a reconnect on HTTP/1, a reset stream on
34/// HTTP/2, and no buffering either way.
35const DRAIN_BUDGET: usize = 64 * 1024;
36
37/// Hook that lets callers apply their own `reqwest::ClientBuilder`
38/// settings. Called once per underlying client (one direct, one per proxy).
39type ConfigureFn = dyn Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send + Sync;
40
41/// An HTTP client that rotates across a pool of proxies, rate-limits
42/// requests per host, and retries transient failures with backoff.
43///
44/// Build one with [`RotatingClient::builder`]. Proxies are optional: with
45/// none configured, `RotatingClient` behaves as a plain rate-limited,
46/// retrying client that ignores proxy environment variables. See the
47/// crate-level docs for what is retried and for a full example.
48///
49/// Cloning is cheap (an `Arc` bump) and clones share everything:
50/// connection pools, proxy cooldown state, and the rate limiter.
51#[derive(Clone, Debug)]
52pub struct RotatingClient {
53 inner: Arc<Inner>,
54}
55
56#[derive(Debug)]
57struct Inner {
58 /// Client used when no proxy is picked for an attempt.
59 direct_client: reqwest::Client,
60 /// One pre-built client per proxy, parallel to `proxies.as_slice()`.
61 proxy_clients: Vec<reqwest::Client>,
62 proxies: ProxyList,
63 rate_limiter: RateLimiter,
64 retries: u32,
65 backoff_base: Duration,
66 backoff_max: Duration,
67 max_retry_after: Duration,
68 proxy_cooldown: Duration,
69}
70
71impl RotatingClient {
72 /// Starts building a [`RotatingClient`].
73 ///
74 /// # Examples
75 ///
76 /// ```
77 /// use reqwest_rotate::RotatingClient;
78 /// use std::time::Duration;
79 ///
80 /// let client = RotatingClient::builder()
81 /// .rate_limit(Duration::from_millis(200))
82 /// .retries(2)
83 /// .build()
84 /// .unwrap();
85 /// # let _ = client;
86 /// ```
87 #[must_use]
88 pub fn builder() -> RotatingClientBuilder {
89 RotatingClientBuilder::default()
90 }
91
92 /// Sends a `GET` request to `url`, applying rate limiting, proxy
93 /// rotation, and retries.
94 ///
95 /// # Errors
96 ///
97 /// Returns [`Error::Reqwest`] when the request cannot be built or when
98 /// the last attempt fails after the retries are used up; see
99 /// [`Error`] for the full set.
100 ///
101 /// # Examples
102 ///
103 /// ```no_run
104 /// # async fn run() -> Result<(), reqwest_rotate::Error> {
105 /// use reqwest_rotate::RotatingClient;
106 ///
107 /// let client = RotatingClient::builder().build()?;
108 /// let response = client.get("https://example.com").await?;
109 /// println!("status: {}", response.status());
110 /// # Ok(())
111 /// # }
112 /// ```
113 pub async fn get(&self, url: impl reqwest::IntoUrl) -> Result<Response, Error> {
114 let request = self.inner.direct_client.get(url).build()?;
115 self.send_with_retry(request).await
116 }
117
118 /// Starts building a request with an arbitrary method, using this
119 /// client's configuration (headers such as `User-Agent`). The returned
120 /// [`RequestBuilder`] wraps [`reqwest::RequestBuilder`]: call
121 /// [`send`](RequestBuilder::send) on it to route the request through
122 /// rate limiting, proxy rotation, and retries, the same as
123 /// [`get`](Self::get) does. Call [`build`](RequestBuilder::build)
124 /// instead if you only want the [`Request`], or
125 /// [`into_inner`](RequestBuilder::into_inner) to get the plain
126 /// `reqwest::RequestBuilder` back — its own `.send()` bypasses
127 /// rotation, rate limiting and retries, sending directly with no proxy.
128 pub fn request(&self, method: reqwest::Method, url: impl reqwest::IntoUrl) -> RequestBuilder {
129 RequestBuilder {
130 client: self.clone(),
131 inner: self.inner.direct_client.request(method, url),
132 }
133 }
134
135 /// Builds `request_builder` and sends it, applying rate limiting, proxy
136 /// rotation, and retries. Takes a plain [`reqwest::RequestBuilder`],
137 /// e.g. one built directly against your own `reqwest::Client`, or a
138 /// [`RequestBuilder`] unwrapped with
139 /// [`into_inner`](RequestBuilder::into_inner). For the common case, call
140 /// [`send`](RequestBuilder::send) on the [`request`](Self::request)
141 /// result directly instead.
142 ///
143 /// # Errors
144 ///
145 /// Returns [`Error::Reqwest`] when the request cannot be built or when
146 /// the last attempt fails after the retries are used up; see
147 /// [`Error`] for the full set.
148 pub async fn send(&self, request_builder: reqwest::RequestBuilder) -> Result<Response, Error> {
149 let request = request_builder.build()?;
150 self.send_with_retry(request).await
151 }
152
153 /// Sends a pre-built [`reqwest::Request`], applying rate limiting,
154 /// proxy rotation, and retries.
155 ///
156 /// # Errors
157 ///
158 /// Returns [`Error::Reqwest`] when the last attempt fails after the
159 /// retries are used up; see [`Error`] for the full set.
160 pub async fn execute(&self, request: Request) -> Result<Response, Error> {
161 self.send_with_retry(request).await
162 }
163
164 /// Returns the [`ProxyList`] this client rotates over: e.g. to inspect
165 /// or react to which proxies are currently in cooldown.
166 ///
167 /// Calling `pick()` on the returned list advances this client's
168 /// rotation and clears an expired cooldown; `as_slice()`, `len()` and
169 /// `in_cooldown()` are the read-only accessors.
170 #[must_use]
171 pub fn proxies(&self) -> &ProxyList {
172 &self.inner.proxies
173 }
174
175 /// Shared retry loop used by [`get`](Self::get), [`send`](Self::send),
176 /// and [`execute`](Self::execute).
177 ///
178 /// Retrying a request means resending the same body, which requires
179 /// cloning it ([`Request::try_clone`]); that only fails for a streaming
180 /// body. The request is cloned on every attempt except the last, where
181 /// the original is sent directly. If a clone is needed but fails, the
182 /// original is sent once and that attempt is treated as the last one:
183 /// the body can't be replayed, but it can at least be sent.
184 async fn send_with_retry(&self, request: Request) -> Result<Response, Error> {
185 let inner = &*self.inner;
186 let idempotent = is_idempotent(request.method());
187 let mut pending = Some(request);
188 let mut attempt: u32 = 0;
189
190 loop {
191 let mut is_last_attempt = attempt >= inner.retries;
192 let current = if is_last_attempt {
193 pending
194 .take()
195 .expect("request is kept until the last attempt")
196 } else {
197 match pending
198 .as_ref()
199 .expect("request is kept until the last attempt")
200 .try_clone()
201 {
202 Some(clone) => clone,
203 None => {
204 is_last_attempt = true;
205 pending
206 .take()
207 .expect("request is kept until the last attempt")
208 }
209 }
210 };
211
212 inner
213 .rate_limiter
214 .wait(current.url().host_str().unwrap_or(""))
215 .await;
216
217 let proxy_idx = inner.proxies.pick_index();
218 let client = match proxy_idx {
219 Some(idx) => &inner.proxy_clients[idx],
220 None => &inner.direct_client,
221 };
222 trace_log!(
223 "attempt {attempt} url={} proxy={:?}",
224 log_url(current.url()),
225 proxy_idx.map(|idx| inner.proxies.redacted(idx))
226 );
227
228 match client.execute(current).await {
229 Ok(response) => {
230 let status = response.status();
231 let blamed_proxy = proxy_idx.filter(|_| is_proxy_failure_status(status));
232 match (blamed_proxy, proxy_idx) {
233 (Some(idx), _) => {
234 trace_log!(
235 "proxy {} answered {status}: cooling it down",
236 inner.proxies.redacted(idx)
237 );
238 inner.proxies.mark_bad_index(idx, inner.proxy_cooldown);
239 }
240 // Any other status is the origin's answer, which means this proxy
241 // forwarded the request: it works, whatever an older failure said.
242 (None, Some(idx)) => inner.proxies.mark_good_index(idx),
243 _ => {}
244 }
245 if is_last_attempt
246 || !(blamed_proxy.is_some() || is_retryable_status(status, idempotent))
247 {
248 return Ok(response);
249 }
250
251 let delay = if let Some(idx) = blamed_proxy {
252 switch_delay(inner, attempt, idx)
253 } else {
254 match retry_after(response.headers()) {
255 Some(asked) if asked > inner.max_retry_after => {
256 trace_log!(
257 "server asked to wait {asked:?}, above max_retry_after: returning {status}"
258 );
259 return Ok(response);
260 }
261 Some(asked) => asked,
262 None => backoff_delay(attempt, inner.backoff_base, inner.backoff_max),
263 }
264 };
265 trace_log!("retrying after {delay:?}, status={status}");
266 drain(response).await;
267 if !delay.is_zero() {
268 tokio::time::sleep(delay).await;
269 }
270 }
271 Err(err) => {
272 // A transport failure seen through a proxy is the
273 // proxy's fault, whether or not this request can be
274 // retried.
275 let blamed_proxy = proxy_idx.filter(|_| is_transport_error(&err));
276 if let Some(idx) = blamed_proxy {
277 trace_log!(
278 "proxy {} failed ({}): cooling it down",
279 inner.proxies.redacted(idx),
280 error_kind(&err)
281 );
282 inner.proxies.mark_bad_index(idx, inner.proxy_cooldown);
283 }
284 if is_last_attempt || !should_retry_error(&err, idempotent) {
285 return Err(Error::Reqwest(err));
286 }
287
288 let delay = if let Some(idx) = blamed_proxy {
289 switch_delay(inner, attempt, idx)
290 } else {
291 backoff_delay(attempt, inner.backoff_base, inner.backoff_max)
292 };
293 trace_log!("retrying after {delay:?} ({} error)", error_kind(&err));
294 if !delay.is_zero() {
295 tokio::time::sleep(delay).await;
296 }
297 }
298 }
299
300 attempt = attempt.saturating_add(1);
301 }
302 }
303}
304
305/// A request builder returned by [`RotatingClient::request`].
306///
307/// This wraps [`reqwest::RequestBuilder`] instead of returning it directly:
308/// the reqwest idiom of calling `.send()` on a plain `RequestBuilder` would
309/// send the request from the direct client, with no proxy rotation, rate
310/// limiting or retries — silently doing the one thing a [`RotatingClient`]
311/// exists to prevent. Call [`send`](Self::send) here instead; it routes
312/// through the same retry loop as [`RotatingClient::get`].
313/// [`into_inner`](Self::into_inner) is the escape hatch for the rare case
314/// you want the plain builder anyway.
315#[derive(Debug)]
316#[must_use = "RequestBuilder does nothing until you call `send` or `build`"]
317pub struct RequestBuilder {
318 client: RotatingClient,
319 inner: reqwest::RequestBuilder,
320}
321
322impl RequestBuilder {
323 fn map(mut self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self {
324 self.inner = f(self.inner);
325 self
326 }
327
328 /// Adds a header. See [`reqwest::RequestBuilder::header`].
329 pub fn header<K, V>(self, key: K, value: V) -> Self
330 where
331 HeaderName: TryFrom<K>,
332 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
333 HeaderValue: TryFrom<V>,
334 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
335 {
336 self.map(|b| b.header(key, value))
337 }
338
339 /// Adds a set of headers, merged into any already set. See
340 /// [`reqwest::RequestBuilder::headers`].
341 pub fn headers(self, headers: HeaderMap) -> Self {
342 self.map(|b| b.headers(headers))
343 }
344
345 /// Enables HTTP basic authentication. See
346 /// [`reqwest::RequestBuilder::basic_auth`].
347 pub fn basic_auth<U, P>(self, username: U, password: Option<P>) -> Self
348 where
349 U: fmt::Display,
350 P: fmt::Display,
351 {
352 self.map(|b| b.basic_auth(username, password))
353 }
354
355 /// Enables HTTP bearer authentication. See
356 /// [`reqwest::RequestBuilder::bearer_auth`].
357 pub fn bearer_auth<T>(self, token: T) -> Self
358 where
359 T: fmt::Display,
360 {
361 self.map(|b| b.bearer_auth(token))
362 }
363
364 /// Sets the request body. See [`reqwest::RequestBuilder::body`].
365 pub fn body<T: Into<reqwest::Body>>(self, body: T) -> Self {
366 self.map(|b| b.body(body))
367 }
368
369 /// Enables a per-request timeout, overriding the client's default. See
370 /// [`reqwest::RequestBuilder::timeout`].
371 pub fn timeout(self, timeout: Duration) -> Self {
372 self.map(|b| b.timeout(timeout))
373 }
374
375 /// Sets the HTTP version. See [`reqwest::RequestBuilder::version`].
376 pub fn version(self, version: reqwest::Version) -> Self {
377 self.map(|b| b.version(version))
378 }
379
380 /// Appends query parameters to the URL. See
381 /// [`reqwest::RequestBuilder::query`].
382 pub fn query<T: serde::Serialize + ?Sized>(self, query: &T) -> Self {
383 self.map(|b| b.query(query))
384 }
385
386 /// Sends a url-encoded form body. See
387 /// [`reqwest::RequestBuilder::form`].
388 pub fn form<T: serde::Serialize + ?Sized>(self, form: &T) -> Self {
389 self.map(|b| b.form(form))
390 }
391
392 /// Sends a JSON body. Needs the `json` feature. See
393 /// [`reqwest::RequestBuilder::json`].
394 #[cfg(feature = "json")]
395 pub fn json<T: serde::Serialize + ?Sized>(self, json: &T) -> Self {
396 self.map(|b| b.json(json))
397 }
398
399 /// Sends a `multipart/form-data` body. Needs the `multipart` feature.
400 /// See [`reqwest::RequestBuilder::multipart`].
401 #[cfg(feature = "multipart")]
402 pub fn multipart(self, form: reqwest::multipart::Form) -> Self {
403 self.map(|b| b.multipart(form))
404 }
405
406 /// Builds the [`Request`] without sending it.
407 ///
408 /// # Errors
409 ///
410 /// Returns [`Error::Reqwest`] if the request could not be built, e.g.
411 /// an invalid header or an unserialisable [`query`](Self::query),
412 /// [`form`](Self::form) or `json` body.
413 pub fn build(self) -> Result<Request, Error> {
414 Ok(self.inner.build()?)
415 }
416
417 /// Sends the request, applying rate limiting, proxy rotation, and
418 /// retries — the same path as [`RotatingClient::get`].
419 ///
420 /// # Errors
421 ///
422 /// Returns [`Error::Reqwest`] when the request cannot be built or when
423 /// the last attempt fails after the retries are used up; see
424 /// [`Error`] for the full set.
425 pub async fn send(self) -> Result<Response, Error> {
426 self.client.send(self.inner).await
427 }
428
429 /// Escapes to the plain [`reqwest::RequestBuilder`]. Its own `.send()`
430 /// bypasses rate limiting, proxy rotation and retries, sending directly
431 /// with no proxy; pass it to [`RotatingClient::send`] to get them back.
432 pub fn into_inner(self) -> reqwest::RequestBuilder {
433 self.inner
434 }
435}
436
437/// Delay before the next attempt after blaming proxy `idx` for this one.
438/// Zero when another proxy is out of cooldown, since the next attempt
439/// already lands on different hardware and there is nothing to wait for;
440/// excluding `idx` itself matters at a zero cooldown, where it would
441/// otherwise count as its own healthy alternative. The usual backoff
442/// otherwise, so a lone dead proxy is not hammered back to back.
443fn switch_delay(inner: &Inner, attempt: u32, idx: usize) -> Duration {
444 if inner.proxies.any_healthy_except(idx) {
445 Duration::ZERO
446 } else {
447 backoff_delay(attempt, inner.backoff_base, inner.backoff_max)
448 }
449}
450
451/// Renders a request URL for log lines: scheme, host, explicit port and
452/// path. Userinfo, query and fragment are dropped; that is where callers
453/// keep their secrets.
454#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
455fn log_url(url: &reqwest::Url) -> String {
456 use std::fmt::Write;
457
458 let mut out = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
459 if let Some(port) = url.port() {
460 let _ = write!(out, ":{port}");
461 }
462 out.push_str(url.path());
463 out
464}
465
466/// Reads roughly [`DRAIN_BUDGET`] bytes of a response body that is about
467/// to be retried. A body that ends within the budget hands its connection
468/// back to the pool; a longer one is dropped mid-stream, which costs a
469/// reconnect on HTTP/1 or a reset stream on HTTP/2. Every chunk is charged
470/// at least one unit, so a stream of empty chunks cannot keep the drain
471/// alive.
472async fn drain(mut response: Response) {
473 let mut budget = DRAIN_BUDGET;
474 while budget > 0 {
475 match response.chunk().await {
476 Ok(Some(chunk)) => budget = spend(budget, chunk.len()),
477 _ => break,
478 }
479 }
480}
481
482/// Charges one chunk against the drain budget; an empty chunk still costs
483/// one unit, so a peer sending nothing but empty frames can't loop forever.
484fn spend(budget: usize, chunk_len: usize) -> usize {
485 budget.saturating_sub(chunk_len.max(1))
486}
487
488/// Builder for [`RotatingClient`]. Construct one with
489/// [`RotatingClient::builder`].
490#[derive(Default)]
491pub struct RotatingClientBuilder {
492 proxies: Vec<String>,
493 proxy_list: Option<ProxyList>,
494 rate_limit: Option<Duration>,
495 retries: Option<u32>,
496 backoff_base: Option<Duration>,
497 backoff_max: Option<Duration>,
498 max_retry_after: Option<Duration>,
499 proxy_cooldown: Option<Duration>,
500 user_agent: Option<String>,
501 timeout: Option<Duration>,
502 connect_timeout: Option<Duration>,
503 configure: Option<Box<ConfigureFn>>,
504}
505
506impl fmt::Debug for RotatingClientBuilder {
507 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
508 f.debug_struct("RotatingClientBuilder")
509 .field(
510 "proxies",
511 &self
512 .proxies
513 .iter()
514 .map(|url| crate::proxy::redact_userinfo(url))
515 .collect::<Vec<_>>(),
516 )
517 .field("proxy_list", &self.proxy_list)
518 .field("rate_limit", &self.rate_limit)
519 .field("retries", &self.retries)
520 .field("backoff_base", &self.backoff_base)
521 .field("backoff_max", &self.backoff_max)
522 .field("max_retry_after", &self.max_retry_after)
523 .field("proxy_cooldown", &self.proxy_cooldown)
524 .field("user_agent", &self.user_agent)
525 .field("timeout", &self.timeout)
526 .field("connect_timeout", &self.connect_timeout)
527 .field("configure", &self.configure.as_ref().map(|_| "<fn>"))
528 .finish()
529 }
530}
531
532impl RotatingClientBuilder {
533 /// Sets the proxy pool to rotate over: `"http://user:pass@host:port"`
534 /// entries, `"https://..."`, a bare `"host:port"` (treated as HTTP), or
535 /// `"socks5://..."` with the `socks` feature. Leave it unset (the
536 /// default) to send requests directly, with no proxy. Ignored if
537 /// [`proxy_list`](Self::proxy_list) is also set.
538 ///
539 /// Proxies are only ever taken from here: the `HTTP_PROXY`,
540 /// `HTTPS_PROXY` and `ALL_PROXY` environment variables that a bare
541 /// `reqwest::Client` picks up are ignored. Pass them explicitly if you
542 /// want them.
543 ///
544 /// Each proxy gets its own underlying `reqwest::Client`, built eagerly
545 /// with its own connection pool and TLS configuration. For pools of
546 /// hundreds of proxies, share one TLS config across them via
547 /// [`configure`](Self::configure) and [`use_preconfigured_tls`][upt].
548 ///
549 /// [upt]: reqwest::ClientBuilder::use_preconfigured_tls
550 #[must_use]
551 pub fn proxies<I, S>(mut self, proxies: I) -> Self
552 where
553 I: IntoIterator<Item = S>,
554 S: AsRef<str>,
555 {
556 self.proxies = proxies.into_iter().map(|s| s.as_ref().to_owned()).collect();
557 self
558 }
559
560 /// Sets the proxy pool directly from a pre-built [`ProxyList`], e.g.
561 /// one you validated up front or already put some proxies on cooldown
562 /// in. Overrides [`proxies`](Self::proxies) if both are set.
563 #[must_use]
564 pub fn proxy_list(mut self, proxy_list: ProxyList) -> Self {
565 self.proxy_list = Some(proxy_list);
566 self
567 }
568
569 /// Minimum interval between two requests to the same host name (port
570 /// and scheme are not part of the key). Unset by default, meaning no
571 /// rate limiting; zero disables it too. An interval over a year is
572 /// capped there.
573 ///
574 /// Every attempt, retries included, waits its turn: a call that
575 /// retries twice takes three slots.
576 ///
577 /// The limiter sees the host of the URL you request. Redirects are
578 /// followed inside `reqwest`, so a redirect to another host is not
579 /// rate-limited separately.
580 ///
581 /// A call cancelled while it is queued for a host (a
582 /// [`tokio::time::timeout`], say) gives its slot back, unless another
583 /// call has already queued behind it.
584 #[must_use]
585 pub const fn rate_limit(mut self, interval: Duration) -> Self {
586 self.rate_limit = Some(interval);
587 self
588 }
589
590 /// How many retries follow the first try. Default: 3, so up to 4
591 /// attempts. `0` disables retries.
592 #[must_use]
593 pub const fn retries(mut self, retries: u32) -> Self {
594 self.retries = Some(retries);
595 self
596 }
597
598 /// Exponential backoff base delay and the cap applied to it. Default:
599 /// 200 ms base, 30 s max, both capped at a year. These pace the delays
600 /// this client computes itself; a wait the server asks for in
601 /// `Retry-After` is bounded separately by
602 /// [`max_retry_after`](Self::max_retry_after).
603 #[must_use]
604 pub const fn backoff(mut self, base: Duration, max: Duration) -> Self {
605 self.backoff_base = Some(base);
606 self.backoff_max = Some(max);
607 self
608 }
609
610 /// Longest `Retry-After` wait that is honoured. Default: 30 s, capped
611 /// at a year.
612 ///
613 /// A `Retry-After` header on a retryable response replaces the computed
614 /// backoff delay. If the server asks for more than this, the response
615 /// is returned instead of retrying early against its wishes. Check the
616 /// status and the header yourself in that case.
617 #[must_use]
618 pub const fn max_retry_after(mut self, max: Duration) -> Self {
619 self.max_retry_after = Some(max);
620 self
621 }
622
623 /// How long a proxy is skipped after it fails, at most: the mark also
624 /// clears the first time the proxy answers again. Default: 60 s. Zero
625 /// never takes a proxy out of rotation, but a retry with nowhere else
626 /// to go is still paced by the backoff.
627 #[must_use]
628 pub const fn proxy_cooldown(mut self, cooldown: Duration) -> Self {
629 self.proxy_cooldown = Some(cooldown);
630 self
631 }
632
633 /// `User-Agent` header sent with every request. Unset by default, in
634 /// which case `reqwest` sends none.
635 #[must_use]
636 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
637 self.user_agent = Some(user_agent.into());
638 self
639 }
640
641 /// Total timeout for one attempt: from starting the request until the
642 /// response body is fully read. Default: 30 s. An attempt that times
643 /// out before the response headers arrive is retried for idempotent
644 /// requests; a `POST` may already be running on the server, so it is
645 /// not. A timeout while *you* read the body surfaces from that read.
646 ///
647 /// This bounds one attempt, not the whole call: with the default four
648 /// attempts a `get()` can take up to about two minutes. Wrap the call
649 /// in [`tokio::time::timeout`] for a hard overall budget.
650 ///
651 /// Pass something huge such as `Duration::MAX` to effectively disable
652 /// it. Not recommended with proxies: one that accepts the connection
653 /// and never answers would then hang a request forever.
654 #[must_use]
655 pub const fn timeout(mut self, timeout: Duration) -> Self {
656 self.timeout = Some(timeout);
657 self
658 }
659
660 /// Timeout for establishing a TCP connection, to the proxy if one is
661 /// used. Default: 10 s.
662 #[must_use]
663 pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
664 self.connect_timeout = Some(timeout);
665 self
666 }
667
668 /// Applies your own settings to every underlying
669 /// [`reqwest::ClientBuilder`] (one direct client plus one per proxy):
670 /// default headers, redirect policy, TLS options, and so on. Runs after
671 /// this builder's own settings, so it can override them.
672 ///
673 /// Anything behind a `reqwest` cargo feature (`gzip`, `brotli`,
674 /// `cookies`, ...) needs that feature enabled on *your* `reqwest`
675 /// dependency; by default this crate turns on `rustls-tls`, `http2` and
676 /// `charset` (swap to the `native-tls` feature, with
677 /// `default-features = false`, for your platform's own TLS instead).
678 /// `json` and `multipart` are this crate's own features, forwarded to
679 /// `reqwest`'s. Once enabled, `gzip`/`brotli` decoding is on by default
680 /// in `reqwest` and needs no call here.
681 ///
682 /// # Examples
683 ///
684 /// ```
685 /// use reqwest_rotate::RotatingClient;
686 ///
687 /// let client = RotatingClient::builder()
688 /// .configure(|builder| {
689 /// builder.redirect(reqwest::redirect::Policy::none())
690 /// })
691 /// .build()
692 /// .unwrap();
693 /// # let _ = client;
694 /// ```
695 #[must_use]
696 pub fn configure<F>(mut self, configure: F) -> Self
697 where
698 F: Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send + Sync + 'static,
699 {
700 self.configure = Some(Box::new(configure));
701 self
702 }
703
704 /// Builds the [`RotatingClient`], constructing one underlying
705 /// `reqwest::Client` per configured proxy plus one direct client.
706 ///
707 /// # Errors
708 ///
709 /// Returns [`Error::InvalidProxy`] if a proxy URL is blank, cannot be
710 /// parsed, or uses an unsupported scheme; or [`Error::Build`] if the
711 /// underlying TLS/client setup fails.
712 pub fn build(self) -> Result<RotatingClient, Error> {
713 let proxies = match self.proxy_list {
714 Some(list) => list,
715 None => ProxyList::new(&self.proxies)?,
716 };
717 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
718 let connect_timeout = self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
719
720 let build_client = |proxy_url: Option<&str>| -> Result<reqwest::Client, Error> {
721 let mut builder = reqwest::Client::builder()
722 .timeout(timeout)
723 .connect_timeout(connect_timeout);
724 if let Some(user_agent) = &self.user_agent {
725 builder = builder.user_agent(user_agent.as_str());
726 }
727 match proxy_url {
728 Some(proxy_url) => {
729 let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| {
730 Error::InvalidProxy {
731 proxy: crate::proxy::redact_userinfo(proxy_url),
732 // `without_url` drops the URL reqwest would
733 // otherwise attach to some of its own errors
734 // and echo back in `Display`/`Debug`, which
735 // would defeat the redaction above.
736 source: Box::new(e.without_url()),
737 }
738 })?;
739 builder = builder.proxy(proxy);
740 }
741 // Without this, reqwest would quietly route "direct"
742 // requests through HTTP_PROXY / HTTPS_PROXY / ALL_PROXY from
743 // the environment, and a 407 from that proxy would look
744 // like an origin response.
745 None => builder = builder.no_proxy(),
746 }
747 if let Some(configure) = &self.configure {
748 builder = configure(builder);
749 }
750 builder.build().map_err(Error::Build)
751 };
752
753 let direct_client = build_client(None)?;
754 let proxy_clients = proxies
755 .as_slice()
756 .iter()
757 .map(|proxy_url| build_client(Some(proxy_url)))
758 .collect::<Result<Vec<_>, _>>()?;
759
760 Ok(RotatingClient {
761 inner: Arc::new(Inner {
762 direct_client,
763 proxy_clients,
764 proxies,
765 rate_limiter: RateLimiter::new(self.rate_limit),
766 retries: self.retries.unwrap_or(DEFAULT_RETRIES),
767 backoff_base: self
768 .backoff_base
769 .unwrap_or(DEFAULT_BACKOFF_BASE)
770 .min(crate::MAX_DURATION),
771 backoff_max: self
772 .backoff_max
773 .unwrap_or(DEFAULT_BACKOFF_MAX)
774 .min(crate::MAX_DURATION),
775 max_retry_after: self
776 .max_retry_after
777 .unwrap_or(DEFAULT_MAX_RETRY_AFTER)
778 .min(crate::MAX_DURATION),
779 proxy_cooldown: self.proxy_cooldown.unwrap_or(DEFAULT_PROXY_COOLDOWN),
780 }),
781 })
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788
789 #[test]
790 fn backoff_base_and_max_are_clamped_to_a_year() {
791 let client = RotatingClient::builder()
792 .backoff(Duration::MAX, Duration::MAX)
793 .build()
794 .unwrap();
795 assert_eq!(client.inner.backoff_base, crate::MAX_DURATION);
796 assert_eq!(client.inner.backoff_max, crate::MAX_DURATION);
797 }
798
799 #[test]
800 fn builder_debug_hides_credentials() {
801 let debug = format!(
802 "{:?}",
803 RotatingClient::builder().proxies([
804 "user:pass@proxy.example:3128",
805 "http://user:p@ss@proxy.example:3128",
806 ])
807 );
808 assert!(!debug.contains("pass"), "{debug}");
809 assert!(!debug.contains("ss@proxy"), "{debug}");
810 assert!(debug.contains("***@proxy.example:3128"), "{debug}");
811 }
812
813 /// `builder_debug_hides_credentials` above only covers the *builder*.
814 /// The struct a user actually holds onto and might log is
815 /// `RotatingClient` itself, whose `Debug` prints the built
816 /// `reqwest::Client`s: today the secret stays out only because
817 /// reqwest's own `Debug` happens to print a proxy's URI without its
818 /// userinfo. This pins that behaviour so a future reqwest release
819 /// changing it would fail this test instead of leaking silently.
820 #[test]
821 fn client_debug_hides_proxy_credentials() {
822 let client = RotatingClient::builder()
823 .proxies(["http://alice:s3cretpw@proxy.example:3128"])
824 .build()
825 .unwrap();
826 let debug = format!("{client:?}");
827 assert!(!debug.contains("s3cretpw"), "{debug}");
828 assert!(!debug.contains("YWxpY2U6czNjcmV0cHc="), "{debug}"); // base64("alice:s3cretpw")
829 assert!(debug.contains("***@proxy.example:3128"), "{debug}");
830 }
831
832 /// The README also promises credentials never reach error messages.
833 /// Port 1 is a reserved TCP port nothing listens on, so the connect
834 /// through the (bad) credentialed proxy fails immediately with no
835 /// server needed; walking the whole `source()` chain, not just the
836 /// top-level message, is the point: `reqwest::Error`'s own `Display`
837 /// is a layer or two above the connect failure that would actually
838 /// carry proxy details if reqwest ever started including them.
839 #[tokio::test]
840 async fn proxy_failure_error_chain_hides_credentials() {
841 let client = RotatingClient::builder()
842 .proxies(["http://alice:s3cretpw@127.0.0.1:1"])
843 .retries(0)
844 .build()
845 .unwrap();
846 let err = client.get("http://example.invalid/").await.unwrap_err();
847
848 let mut chain = err.to_string();
849 let mut source = std::error::Error::source(&err);
850 while let Some(e) = source {
851 chain.push_str(" <- ");
852 chain.push_str(&e.to_string());
853 source = e.source();
854 }
855
856 assert!(!chain.contains("s3cretpw"), "{chain}");
857 assert!(!chain.contains("YWxpY2U6czNjcmV0cHc="), "{chain}"); // base64("alice:s3cretpw")
858 assert!(!chain.contains("alice"), "{chain}");
859 }
860
861 #[test]
862 fn proxies_accepts_a_slice_of_str_refs() {
863 // Compiling is the test: a caller who reads proxies into a
864 // `Vec<&str>` and passes `&proxies` (keeping ownership of the
865 // `Vec` for later use) used to hit `String: From<&&str>` is not
866 // satisfied, even though the equivalent `ProxyList::new(&proxies)`
867 // already accepted this shape via `AsRef<str>`.
868 let proxies: Vec<&str> = vec!["http://a", "http://b"];
869 let client = RotatingClient::builder().proxies(&proxies).build().unwrap();
870 assert_eq!(client.proxies().len(), 2);
871 assert_eq!(proxies.len(), 2);
872 }
873
874 #[test]
875 fn max_retry_after_is_clamped_to_a_year() {
876 let client = RotatingClient::builder()
877 .max_retry_after(Duration::MAX)
878 .build()
879 .unwrap();
880 assert_eq!(client.inner.max_retry_after, crate::MAX_DURATION);
881 }
882
883 #[test]
884 fn drain_budget_always_shrinks() {
885 assert_eq!(spend(10, 0), 9);
886 assert_eq!(spend(10, 4), 6);
887 assert_eq!(spend(1, 0), 0);
888 assert_eq!(spend(3, 10), 0);
889 }
890
891 #[test]
892 fn log_url_keeps_only_scheme_host_port_path() {
893 assert_eq!(
894 log_url(&reqwest::Url::parse("http://u:p@h:8080/v1/data?api_key=SECRET#f").unwrap()),
895 "http://h:8080/v1/data"
896 );
897 assert_eq!(
898 log_url(&reqwest::Url::parse("http://h/path").unwrap()),
899 "http://h/path"
900 );
901 assert_eq!(
902 log_url(&reqwest::Url::parse("https://h:443/").unwrap()),
903 "https://h/"
904 );
905 assert_eq!(
906 log_url(&reqwest::Url::parse("http://[::1]:8080/p").unwrap()),
907 "http://[::1]:8080/p"
908 );
909 }
910}