Skip to main content

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}