Skip to main content

reqwest_rotate/
lib.rs

1//! `reqwest-rotate` bundles the three things almost every scraper or API
2//! client needs on top of [`reqwest`]: rotating across a pool of proxies,
3//! rate-limiting requests per host, and retrying transient failures with
4//! backoff that honours `Retry-After`.
5//!
6//! It is deliberately small: one client, one builder, no middleware traits.
7//! Proxies are optional. Without them [`RotatingClient`] is just a
8//! rate-limited, retrying wrapper around `reqwest` with sane timeouts.
9//!
10//! # Example
11//!
12//! ```no_run
13//! use reqwest_rotate::RotatingClient;
14//! use std::time::Duration;
15//!
16//! # async fn run() -> Result<(), reqwest_rotate::Error> {
17//! let client = RotatingClient::builder()
18//!     .proxies([
19//!         "http://user:pass@proxy1.example:8000",
20//!         "http://user:pass@proxy2.example:8000",
21//!     ])
22//!     .rate_limit(Duration::from_millis(500))
23//!     .retries(4)
24//!     .backoff(Duration::from_millis(200), Duration::from_secs(30))
25//!     .proxy_cooldown(Duration::from_secs(60))
26//!     .timeout(Duration::from_secs(20))
27//!     .user_agent("my-scraper/0.1")
28//!     .build()?;
29//!
30//! let response = client.get("https://example.com/api").await?;
31//! println!("status: {}", response.status());
32//! # Ok(())
33//! # }
34//! ```
35//!
36//! # What gets retried
37//!
38//! - Responses with status 408, 429 or 503, for every request; other 5xx
39//!   (except 501 and 505) only for idempotent requests, since a `POST` the
40//!   server processed and then failed to answer would be duplicated.
41//!   Backoff is full-jitter exponential; a `Retry-After` header, when
42//!   present, replaces the computed delay. If the server asks for a wait
43//!   longer than `max_retry_after` (30 s by default), the response is
44//!   returned right away instead of retrying early against its wishes.
45//! - A `407` answered by a configured proxy, for every request including
46//!   `POST`: the proxy did not forward anything, so nothing can be
47//!   duplicated. The proxy is put in cooldown and the next attempt goes
48//!   through another one; a `Retry-After` on that `407` is deliberately
49//!   not honoured, since the wait belongs to the failed proxy, not to the
50//!   server.
51//! - Transport errors that prove the request never reached the server:
52//!   connect failures (including connect timeouts), requests cancelled
53//!   before dispatch, HTTP/2 `REFUSED_STREAM`. Retried for every request.
54//! - Other transport errors: a total-request timeout, a connection closed
55//!   or reset before the response arrived (the classic keep-alive race of
56//!   long-running scrapers), an HTTP/2 `GOAWAY` or stream reset. Retried
57//!   only for idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`,
58//!   `DELETE`, `TRACE`).
59//!
60//! After the last attempt the response is returned as-is, whatever its
61//! status, and a transport error is returned as [`Error::Reqwest`]. Its
62//! message is the short `request failed`; the cause is the error's
63//! [`source`](std::error::Error::source), which `anyhow`'s `{:#}` and most
64//! reporters print for you. Each attempt is bounded by the timeouts below,
65//! not the whole call; wrap the call in [`tokio::time::timeout`] for a
66//! hard overall budget.
67//!
68//! # Proxies
69//!
70//! Proxies are used round-robin. A proxy that fails at the transport level
71//! (connect failure, timeout, dropped or reset connection) or answers
72//! `407 Proxy Authentication Required` is put on cooldown and skipped until
73//! the cooldown expires, or until it answers a request again, whichever
74//! comes first. A per-attempt timeout counts as the proxy's failure, since
75//! the client cannot tell a stalled proxy from a stalled origin; blaming
76//! it is cheap: the mark clears the first time the proxy answers again.
77//! While another proxy is out of cooldown, the retry goes through it right
78//! away; once no other proxy is available, retries are paced by the
79//! backoff. Any other status is the origin's answer, and the proxy keeps
80//! its place in the rotation.
81//!
82//! Only proxies you configure are used: the `HTTP_PROXY`, `HTTPS_PROXY`
83//! and `ALL_PROXY` environment variables are ignored. `http://` and
84//! `https://` proxies work out of the box; `socks5://` and friends need
85//! the `socks` cargo feature (without it they are rejected by `build()`
86//! instead of silently misbehaving). A bare `host:port` is accepted and
87//! treated as `http://host:port`. Proxy credentials are hidden from
88//! `Debug` output, error messages, and `tracing` events.
89//!
90//! # Timeouts
91//!
92//! A bare `reqwest::Client` has none by default. This one has both: 30 s
93//! for the whole attempt, 10 s to connect, each configurable on the
94//! builder. They bound one attempt, not the whole call.
95//!
96//! One trap for tests using `#[tokio::test(start_paused = true)]`: tokio's
97//! auto-advancing clock fires the request timeout the moment a task blocks
98//! on real socket I/O. Use real time against a local server.
99//!
100//! # Sharing
101//!
102//! `RotatingClient` is cheap to clone: clones share the connection pools,
103//! the proxy cooldown state, and the rate limiter.
104
105#![forbid(unsafe_code)]
106#![warn(missing_docs)]
107
108mod client;
109mod error;
110mod proxy;
111mod rate_limit;
112mod retry;
113
114/// Longest duration this crate's clamped knobs honour, and the ceiling
115/// used when a configured duration would overflow `Instant` arithmetic.
116/// Anything above it (only absurd values such as `Duration::MAX`) is
117/// treated as this, so no arithmetic on a configured duration can
118/// quietly turn a limit into no limit at all.
119pub(crate) const MAX_DURATION: std::time::Duration =
120    std::time::Duration::from_secs(60 * 60 * 24 * 365);
121
122pub use client::{RequestBuilder, RotatingClient, RotatingClientBuilder};
123pub use error::Error;
124pub use proxy::ProxyList;
125
126/// `tracing::debug!` with the `tracing` feature on, nothing without it, so
127/// call sites need no `#[cfg]` of their own. Helpers that exist only to
128/// feed these call sites carry `allow(dead_code)` for feature-off builds.
129macro_rules! trace_log {
130    ($($arg:tt)*) => {
131        #[cfg(feature = "tracing")]
132        tracing::debug!($($arg)*);
133    };
134}
135pub(crate) use trace_log;