hyperi-rustlib 2.5.1

Opinionated Rust framework for high-throughput data pipelines at PB scale. Auto-wiring config, logging, metrics, tracing, health, and graceful shutdown — built from many years of production infrastructure experience.
Documentation
// Project:   hyperi-rustlib
// File:      src/http_client/mod.rs
// Purpose:   Production HTTP client with retry middleware
// Language:  Rust
//
// License:   FSL-1.1-ALv2
// Copyright: (c) 2026 HYPERI PTY LIMITED

//! Production HTTP client with automatic retries and timeouts.
//!
//! Wraps [`reqwest`] with [`reqwest_middleware`] and [`reqwest_retry`] to
//! provide exponential backoff for transient errors (5xx, timeouts,
//! connection failures). Non-retryable errors (4xx) return immediately.
//!
//! # Config Cascade
//!
//! When the `config` feature is enabled, config is auto-loaded from the
//! cascade under the `http_client` key:
//!
//! ```yaml
//! http_client:
//!   timeout_secs: 30
//!   connect_timeout_secs: 10
//!   max_retries: 3
//!   min_retry_interval_ms: 100
//!   max_retry_interval_ms: 30000
//!   user_agent: "dfe-fetcher/1.0"
//! ```

pub mod config;

pub use config::HttpClientConfig;

use reqwest::Response;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::RetryTransientMiddleware;
use reqwest_retry::policies::ExponentialBackoff;

/// HTTP client build error.
#[derive(Debug, thiserror::Error)]
pub enum HttpClientError {
    /// Failed to build the underlying reqwest client.
    #[error("failed to build HTTP client: {0}")]
    BuildError(#[from] reqwest::Error),
}

/// Production HTTP client with retry middleware.
pub struct HttpClient {
    inner: ClientWithMiddleware,
    config: HttpClientConfig,
}

impl HttpClient {
    /// Create a new HTTP client with the given config.
    ///
    /// Panics if the underlying TLS backend fails to initialise.
    /// Prefer [`try_new`](Self::try_new) for fallible construction.
    #[must_use]
    pub fn new(config: HttpClientConfig) -> Self {
        Self::try_new(config).expect("failed to build reqwest client (TLS init failure)")
    }

    /// Create a new HTTP client, returning an error on build failure.
    ///
    /// # Errors
    ///
    /// Returns [`HttpClientError::BuildError`] if the underlying reqwest
    /// client cannot be constructed (typically TLS backend init failure).
    pub fn try_new(config: HttpClientConfig) -> Result<Self, HttpClientError> {
        let retry_policy = ExponentialBackoff::builder()
            .retry_bounds(
                std::time::Duration::from_millis(config.min_retry_interval_ms),
                std::time::Duration::from_millis(config.max_retry_interval_ms),
            )
            .build_with_max_retries(config.max_retries);

        let mut builder = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(config.timeout_secs))
            .connect_timeout(std::time::Duration::from_secs(config.connect_timeout_secs));

        if let Some(ref ua) = config.user_agent {
            builder = builder.user_agent(ua.clone());
        }

        let reqwest_client = builder.build()?;

        let client = ClientBuilder::new(reqwest_client)
            .with(RetryTransientMiddleware::new_with_policy(retry_policy))
            .build();

        Ok(Self {
            inner: client,
            config,
        })
    }

    /// Create a client from the config cascade (or defaults).
    #[must_use]
    pub fn from_cascade() -> Self {
        Self::new(HttpClientConfig::from_cascade())
    }

    /// Send a GET request.
    pub async fn get(&self, url: &str) -> Result<Response, reqwest_middleware::Error> {
        #[cfg(feature = "metrics")]
        let start = std::time::Instant::now();

        let result = self.inner.get(url).send().await;

        #[cfg(feature = "metrics")]
        {
            let status = if result.is_ok() { "success" } else { "error" };
            metrics::counter!("dfe_http_client_requests_total", "method" => "GET", "status" => status).increment(1);
            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "GET")
                .record(start.elapsed().as_secs_f64());
        }

        result
    }

    /// Send a POST request with a JSON body.
    pub async fn post_json<T: serde::Serialize + ?Sized>(
        &self,
        url: &str,
        body: &T,
    ) -> Result<Response, reqwest_middleware::Error> {
        #[cfg(feature = "metrics")]
        let start = std::time::Instant::now();

        let result = self
            .inner
            .post(url)
            .header("content-type", "application/json")
            .body(serde_json::to_vec(body).unwrap_or_default())
            .send()
            .await;

        #[cfg(feature = "metrics")]
        {
            let status = if result.is_ok() { "success" } else { "error" };
            metrics::counter!("dfe_http_client_requests_total", "method" => "POST", "status" => status).increment(1);
            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "POST")
                .record(start.elapsed().as_secs_f64());
        }

        result
    }

    /// Send a PUT request with a JSON body.
    pub async fn put_json<T: serde::Serialize + ?Sized>(
        &self,
        url: &str,
        body: &T,
    ) -> Result<Response, reqwest_middleware::Error> {
        #[cfg(feature = "metrics")]
        let start = std::time::Instant::now();

        let result = self
            .inner
            .put(url)
            .header("content-type", "application/json")
            .body(serde_json::to_vec(body).unwrap_or_default())
            .send()
            .await;

        #[cfg(feature = "metrics")]
        {
            let status = if result.is_ok() { "success" } else { "error" };
            metrics::counter!("dfe_http_client_requests_total", "method" => "PUT", "status" => status).increment(1);
            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "PUT")
                .record(start.elapsed().as_secs_f64());
        }

        result
    }

    /// Send a DELETE request.
    pub async fn delete(&self, url: &str) -> Result<Response, reqwest_middleware::Error> {
        #[cfg(feature = "metrics")]
        let start = std::time::Instant::now();

        let result = self.inner.delete(url).send().await;

        #[cfg(feature = "metrics")]
        {
            let status = if result.is_ok() { "success" } else { "error" };
            metrics::counter!("dfe_http_client_requests_total", "method" => "DELETE", "status" => status).increment(1);
            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "DELETE")
                .record(start.elapsed().as_secs_f64());
        }

        result
    }

    /// Access the underlying middleware client for custom requests.
    #[must_use]
    pub fn client(&self) -> &ClientWithMiddleware {
        &self.inner
    }

    /// Access the current config.
    #[must_use]
    pub fn config(&self) -> &HttpClientConfig {
        &self.config
    }
}