use reqwest::blocking::{RequestBuilder, Response};
use std::time::Duration;
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const ATTEMPTS: u32 = 3;
const FIRST_BACKOFF: Duration = Duration::from_secs(1);
const MAX_BACKOFF: Duration = Duration::from_secs(30);
pub fn send_idempotent(
what: &str,
build: impl Fn() -> RequestBuilder,
) -> reqwest::Result<Response> {
send_with_backoff(what, FIRST_BACKOFF, build)
}
fn send_with_backoff(
what: &str,
first_backoff: Duration,
build: impl Fn() -> RequestBuilder,
) -> reqwest::Result<Response> {
let mut backoff = first_backoff;
for attempt in 1..=ATTEMPTS {
let last = attempt == ATTEMPTS;
match build().send() {
Ok(response) => {
if last || !worth_retrying(response.status()) {
return Ok(response);
}
let wait = retry_after(&response).unwrap_or(backoff);
announce(what, &format!("{}", response.status()), attempt, wait);
std::thread::sleep(wait);
}
Err(error) => {
if last || !transient(&error) {
return Err(error);
}
announce(what, &describe(&error), attempt, backoff);
std::thread::sleep(backoff);
}
}
backoff = (backoff * 2).min(MAX_BACKOFF);
}
build().send()
}
fn announce(what: &str, reason: &str, attempt: u32, wait: Duration) {
eprintln!(
" {what}: {reason} — retrying in {:.0}s (attempt {attempt} of {ATTEMPTS})",
wait.as_secs_f64()
);
}
fn transient(error: &reqwest::Error) -> bool {
error.is_connect() || error.is_timeout()
}
fn worth_retrying(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
}
fn retry_after(response: &Response) -> Option<Duration> {
let seconds: u64 = response
.headers()
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(seconds).min(MAX_BACKOFF))
}
fn describe(error: &reqwest::Error) -> String {
if error.is_timeout() {
"timed out".to_string()
} else {
"could not connect".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testserver::{Reply, serve};
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.connect_timeout(CONNECT_TIMEOUT)
.build()
.unwrap()
}
#[test]
fn a_transient_server_error_is_retried_and_then_succeeds() {
let server = serve(vec![
Reply::status(502, r#"{"error":"bad gateway"}"#),
Reply::json(r#"{"done":true}"#),
]);
let url = server.url().to_string();
let http = client();
let response =
send_with_backoff("polling", Duration::from_millis(1), || http.get(&url)).unwrap();
assert!(response.status().is_success());
assert_eq!(server.finish().len(), 2, "the first attempt must have happened");
}
#[test]
fn a_client_error_is_returned_at_once() {
let server = serve(vec![Reply::status(401, r#"{"error":"bad key"}"#)]);
let url = server.url().to_string();
let http = client();
let response =
send_with_backoff("checking", Duration::from_millis(1), || http.get(&url)).unwrap();
assert_eq!(response.status(), 401);
assert_eq!(server.finish().len(), 1, "a 401 must not be asked twice");
}
#[test]
fn a_persistent_failure_gives_the_caller_the_last_response() {
let server = serve(vec![
Reply::status(503, "{}"),
Reply::status(503, "{}"),
Reply::status(503, "{}"),
]);
let url = server.url().to_string();
let http = client();
let response =
send_with_backoff("polling", Duration::from_millis(1), || http.get(&url)).unwrap();
assert_eq!(response.status(), 503);
assert_eq!(server.finish().len(), ATTEMPTS as usize);
}
#[test]
fn a_refused_connection_is_retried_before_giving_up() {
let http = client();
let error = send_with_backoff("polling", Duration::from_millis(1), || {
http.get("http://127.0.0.1:1/nothing")
})
.unwrap_err();
assert!(error.is_connect(), "{error}");
}
#[test]
fn a_retry_after_header_overrides_the_backoff() {
let server = serve(vec![
Reply::status(429, "{}").with_header("Retry-After", "0"),
Reply::json("{}"),
]);
let url = server.url().to_string();
let http = client();
let response =
send_with_backoff("polling", Duration::from_secs(10), || http.get(&url)).unwrap();
assert!(response.status().is_success());
assert_eq!(server.finish().len(), 2);
}
#[test]
fn every_http_client_sets_a_connect_timeout() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let needle = format!("Client::{}", "builder()");
let timeout = format!(".connect_{}(", "timeout");
for entry in std::fs::read_dir(&src).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
for (index, _) in text.match_indices(needle.as_str()) {
let chain = &text[index..];
let end = chain.find(".build()").unwrap_or(chain.len());
assert!(
chain[..end].contains(timeout.as_str()),
"{}: an HTTP client is built without a connect timeout, so a \
blackholed host will hang it for the whole request timeout",
path.display()
);
}
}
}
#[test]
fn an_absurd_retry_after_is_capped() {
let server = serve(vec![Reply::status(429, "{}").with_header("Retry-After", "86400")]);
let http = client();
let response = http.get(server.url()).send().unwrap();
assert_eq!(retry_after(&response), Some(MAX_BACKOFF));
server.finish();
}
}