pub mod config;
pub use config::HttpClientConfig;
use std::time::Duration;
use backon::{ExponentialBuilder, Retryable};
use reqwest::{Client, RequestBuilder, Response, StatusCode};
#[derive(Debug, thiserror::Error)]
pub enum HttpClientError {
#[error("failed to build HTTP client: {0}")]
BuildError(#[from] reqwest::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum HttpError {
#[error("HTTP transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("HTTP retryable status: {}", .0.status())]
Status(Box<Response>),
#[error("JSON serialise failed: {0}")]
Serialize(#[from] serde_json::Error),
}
impl HttpError {
fn is_retryable(&self) -> bool {
match self {
Self::Transport(e) => e.is_timeout() || e.is_connect(),
Self::Status(_) => true,
Self::Serialize(_) => false,
}
}
fn retry_after(&self) -> Option<Duration> {
let Self::Status(resp) = self else {
return None;
};
let secs: u64 = resp
.headers()
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(secs))
}
}
fn is_retryable_status(status: StatusCode) -> bool {
matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
}
pub struct HttpClient {
inner: Client,
config: HttpClientConfig,
}
impl HttpClient {
pub fn new(config: HttpClientConfig) -> Result<Self, HttpClientError> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.connect_timeout(Duration::from_secs(config.connect_timeout_secs));
if let Some(ref ua) = config.user_agent {
builder = builder.user_agent(ua.clone());
}
Ok(Self {
inner: builder.build()?,
config,
})
}
pub fn from_cascade() -> Result<Self, HttpClientError> {
Self::new(HttpClientConfig::from_cascade())
}
fn backoff(&self) -> ExponentialBuilder {
ExponentialBuilder::new()
.with_min_delay(Duration::from_millis(self.config.min_retry_interval_ms))
.with_max_delay(Duration::from_millis(self.config.max_retry_interval_ms))
.with_max_times(self.config.max_retries as usize)
.with_jitter()
}
async fn execute(
&self,
method: &'static str,
idempotent: bool,
make: impl Fn() -> RequestBuilder,
) -> Result<Response, HttpError> {
let attempt = || async {
let resp = make().send().await?;
if is_retryable_status(resp.status()) {
return Err(HttpError::Status(Box::new(resp)));
}
Ok(resp)
};
let retry_enabled =
self.config.max_retries > 0 && (idempotent || self.config.retry_non_idempotent);
let result = if retry_enabled {
attempt
.retry(self.backoff())
.when(HttpError::is_retryable)
.adjust(|e: &HttpError, candidate| e.retry_after().or(candidate))
.sleep(tokio::time::sleep)
.notify(|_e: &HttpError, _dur: Duration| Self::record_retry(method))
.await
} else {
attempt().await
};
match result {
Ok(resp) => Ok(resp),
Err(HttpError::Status(resp)) => Ok(*resp),
Err(e) => Err(e),
}
}
#[cfg_attr(not(feature = "metrics"), allow(unused_variables))]
fn record_retry(method: &'static str) {
#[cfg(feature = "metrics")]
metrics::counter!("http_client_retries_total", "method" => method).increment(1);
}
#[cfg_attr(not(feature = "metrics"), allow(unused_variables))]
fn record(method: &'static str, ok: bool, start: std::time::Instant) {
#[cfg(feature = "metrics")]
{
let status = if ok { "success" } else { "error" };
metrics::counter!("http_client_requests_total", "method" => method, "status" => status)
.increment(1);
metrics::histogram!("http_client_duration_seconds", "method" => method)
.record(start.elapsed().as_secs_f64());
}
}
pub async fn get(&self, url: &str) -> Result<Response, HttpError> {
let start = std::time::Instant::now();
let result = self.execute("GET", true, || self.inner.get(url)).await;
Self::record("GET", result.is_ok(), start);
result
}
pub async fn post_json<T: serde::Serialize + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<Response, HttpError> {
let start = std::time::Instant::now();
let body_bytes = serde_json::to_vec(body)?;
let result = self
.execute("POST", false, || {
self.inner
.post(url)
.header("content-type", "application/json")
.body(body_bytes.clone())
})
.await;
Self::record("POST", result.is_ok(), start);
result
}
pub async fn put_json<T: serde::Serialize + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<Response, HttpError> {
let start = std::time::Instant::now();
let body_bytes = serde_json::to_vec(body)?;
let result = self
.execute("PUT", true, || {
self.inner
.put(url)
.header("content-type", "application/json")
.body(body_bytes.clone())
})
.await;
Self::record("PUT", result.is_ok(), start);
result
}
pub async fn delete(&self, url: &str) -> Result<Response, HttpError> {
let start = std::time::Instant::now();
let result = self
.execute("DELETE", true, || self.inner.delete(url))
.await;
Self::record("DELETE", result.is_ok(), start);
result
}
#[must_use]
pub fn client(&self) -> &Client {
&self.inner
}
#[must_use]
pub fn config(&self) -> &HttpClientConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retryable_status_set() {
for code in [408, 429, 500, 502, 503, 504] {
assert!(is_retryable_status(StatusCode::from_u16(code).unwrap()));
}
for code in [200, 201, 301, 400, 401, 404, 409, 501] {
assert!(!is_retryable_status(StatusCode::from_u16(code).unwrap()));
}
}
#[test]
fn serialise_error_not_retryable() {
let err = serde_json::from_str::<i32>("not a number").unwrap_err();
let http_err = HttpError::Serialize(err);
assert!(!http_err.is_retryable());
assert!(http_err.retry_after().is_none());
}
#[tokio::test]
async fn build_client_from_default_config() {
let client = HttpClient::new(HttpClientConfig::default()).unwrap();
assert_eq!(client.config().max_retries, 3);
let _ = client.backoff();
}
}