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;