//! Shared HTTP client: `reqwest_middleware::ClientWithMiddleware` with a //! retry stack (RetryTransientMiddleware + inlined RetryAfterMiddleware), //! connection pooling, keep-alive, TLS, and rebuild-and-swap hot-reload. //! //! Credential injection happens per-request (from //! `OperationContext.capabilities`), not at client construction — the //! client is shared across all operations, the credentials are per-call. use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use arc_swap::ArcSwap; use reqwest::ClientBuilder; use reqwest_middleware::ClientWithMiddleware; use reqwest_retry::policies::ExponentialBackoff; use reqwest_retry::RetryTransientMiddleware; use thiserror::Error; use super::retry_after::RetryAfterMiddleware; const DEFAULT_RETRY_AFTER_CAPACITY: usize = 256; #[derive(Debug, Clone)] pub struct ClientCertConfig { pub cert_pem: PathBuf, pub key_pem: PathBuf, } #[derive(Debug, Clone)] pub struct HttpClientConfig { pub pool_max_idle_per_host: Option, pub request_timeout: Option, pub retry_policy: ExponentialBackoff, pub ca_bundle: Option, pub client_cert: Option, } impl Default for HttpClientConfig { fn default() -> Self { Self { pool_max_idle_per_host: None, request_timeout: None, retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), ca_bundle: None, client_cert: None, } } } #[derive(Debug, Error)] pub enum HttpClientBuildError { #[error("failed to read CA bundle from {path}: {source}")] CaBundleRead { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to parse CA bundle at {path}: {source}")] CaBundleParse { path: PathBuf, #[source] source: reqwest::Error, }, #[error("failed to read client cert from {path}: {source}")] ClientCertRead { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to parse client cert at {path}: {source}")] ClientCertParse { path: PathBuf, #[source] source: reqwest::Error, }, #[error("failed to build reqwest client: {0}")] Build(reqwest::Error), } pub struct SharedHttpClient { inner: ArcSwap, config: ArcSwap, } impl std::fmt::Debug for SharedHttpClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SharedHttpClient") .field("config", &self.config.load()) .finish_non_exhaustive() } } impl SharedHttpClient { pub fn new(config: HttpClientConfig) -> Result { let client = build_client(&config)?; Ok(Self { inner: ArcSwap::from_pointee(client), config: ArcSwap::from_pointee(config), }) } pub fn client(&self) -> Arc { self.inner.load_full() } pub fn config(&self) -> Arc { self.config.load_full() } pub fn reload(&self, config: HttpClientConfig) -> Result<(), HttpClientBuildError> { let client = build_client(&config)?; self.config.store(Arc::new(config)); self.inner.store(Arc::new(client)); Ok(()) } } fn build_client(config: &HttpClientConfig) -> Result { let mut builder = ClientBuilder::new(); if let Some(pool_max_idle) = config.pool_max_idle_per_host { builder = builder.pool_max_idle_per_host(pool_max_idle); } if let Some(timeout) = config.request_timeout { builder = builder.timeout(timeout); } if let Some(ca_bundle_path) = &config.ca_bundle { let pem = std::fs::read(ca_bundle_path).map_err(|source| HttpClientBuildError::CaBundleRead { path: ca_bundle_path.clone(), source, })?; let certs = reqwest::Certificate::from_pem_bundle(&pem).map_err(|source| { HttpClientBuildError::CaBundleParse { path: ca_bundle_path.clone(), source, } })?; for cert in certs { builder = builder.add_root_certificate(cert); } } if let Some(client_cert_cfg) = &config.client_cert { let cert_pem = std::fs::read(&client_cert_cfg.cert_pem).map_err(|source| { HttpClientBuildError::ClientCertRead { path: client_cert_cfg.cert_pem.clone(), source, } })?; let key_pem = std::fs::read(&client_cert_cfg.key_pem).map_err(|source| { HttpClientBuildError::ClientCertRead { path: client_cert_cfg.key_pem.clone(), source, } })?; let identity = reqwest::Identity::from_pem(concat_pem(&cert_pem, &key_pem).as_slice()) .map_err(|source| HttpClientBuildError::ClientCertParse { path: client_cert_cfg.cert_pem.clone(), source, })?; builder = builder.identity(identity); } let reqwest_client = builder.build().map_err(HttpClientBuildError::Build)?; let client = reqwest_middleware::ClientBuilder::new(reqwest_client) .with(RetryTransientMiddleware::new_with_policy( config.retry_policy, )) .with(RetryAfterMiddleware::with_capacity( DEFAULT_RETRY_AFTER_CAPACITY, )) .build(); Ok(client) } fn concat_pem(cert: &[u8], key: &[u8]) -> Vec { let mut combined = Vec::with_capacity(cert.len() + key.len() + 1); combined.extend_from_slice(cert); if !cert.is_empty() && cert.last() != Some(&b'\n') { combined.push(b'\n'); } combined.extend_from_slice(key); combined } #[cfg(test)] mod tests { use super::*; use std::time::SystemTime; fn minimal_config() -> HttpClientConfig { HttpClientConfig { pool_max_idle_per_host: Some(8), request_timeout: Some(Duration::from_secs(30)), retry_policy: ExponentialBackoff::builder().build_with_max_retries(2), ca_bundle: None, client_cert: None, } } #[test] fn client_returns_a_usable_client_with_middleware() { let http = SharedHttpClient::new(minimal_config()).expect("client builds"); let client = http.client(); let request = client .get("https://api.example.com/v1/chat") .build() .expect("RequestBuilder builds"); assert_eq!(request.method(), reqwest::Method::GET); assert_eq!(request.url().as_str(), "https://api.example.com/v1/chat"); } #[test] fn reload_swaps_the_client_returned_by_client() { let http = SharedHttpClient::new(minimal_config()).expect("client builds"); let before = http.client(); let new_config = HttpClientConfig { pool_max_idle_per_host: Some(32), request_timeout: Some(Duration::from_secs(10)), retry_policy: ExponentialBackoff::builder().build_with_max_retries(5), ca_bundle: None, client_cert: None, }; http.reload(new_config.clone()).expect("reload succeeds"); let after = http.client(); assert!( !Arc::ptr_eq(&before, &after), "reload must swap in a new ClientWithMiddleware" ); let config = http.config(); assert_eq!(config.pool_max_idle_per_host, Some(32)); assert_eq!(config.request_timeout, Some(Duration::from_secs(10))); } #[test] fn config_returns_current_config() { let http = SharedHttpClient::new(minimal_config()).expect("client builds"); let config = http.config(); assert_eq!(config.pool_max_idle_per_host, Some(8)); assert_eq!(config.request_timeout, Some(Duration::from_secs(30))); } #[test] fn default_config_has_sensible_defaults() { let config = HttpClientConfig::default(); assert!(config.pool_max_idle_per_host.is_none()); assert!(config.request_timeout.is_none()); assert!(config.ca_bundle.is_none()); assert!(config.client_cert.is_none()); assert_eq!(config.retry_policy.max_n_retries, Some(3)); } #[test] fn reload_with_ca_bundle_missing_file_errors() { let http = SharedHttpClient::new(minimal_config()).expect("client builds"); let bad_config = HttpClientConfig { ca_bundle: Some(PathBuf::from("/nonexistent/ca-bundle.pem")), ..minimal_config() }; let err = http.reload(bad_config).unwrap_err(); assert!(matches!(err, HttpClientBuildError::CaBundleRead { .. })); } #[test] fn concat_pem_inserts_separator_between_cert_and_key() { let cert = b"-----BEGIN CERTIFICATE-----\ncert-body\n-----END CERTIFICATE-----"; let key = b"-----BEGIN PRIVATE KEY-----\nkey-body\n-----END PRIVATE KEY-----"; let combined = concat_pem(cert, key); assert!(combined.starts_with(b"-----BEGIN CERTIFICATE-----")); assert!(combined.windows(20).any(|w| w == b"-----END CERTIFICATE")); assert!(combined.windows(18).any(|w| w == b"-----BEGIN PRIVATE")); } #[test] fn concat_pem_handles_cert_already_terminated_with_newline() { let cert = b"-----BEGIN CERTIFICATE-----\ncert-body\n-----END CERTIFICATE-----\n"; let key = b"-----BEGIN PRIVATE KEY-----\nkey-body\n-----END PRIVATE KEY-----"; let combined = concat_pem(cert, key); let joined = std::str::from_utf8(&combined).unwrap(); assert!( !joined.contains("-----END CERTIFICATE----------BEGIN PRIVATE"), "must not concatenate without a separator when cert lacks trailing newline" ); assert!(joined.contains("-----END CERTIFICATE-----\n-----BEGIN PRIVATE")); } #[test] fn client_cert_config_constructs() { let cfg = ClientCertConfig { cert_pem: PathBuf::from("/etc/cert.pem"), key_pem: PathBuf::from("/etc/key.pem"), }; assert_eq!(cfg.cert_pem, PathBuf::from("/etc/cert.pem")); assert_eq!(cfg.key_pem, PathBuf::from("/etc/key.pem")); } #[test] fn new_with_missing_ca_bundle_errors() { let config = HttpClientConfig { ca_bundle: Some(PathBuf::from("/nonexistent/ca-bundle.pem")), ..HttpClientConfig::default() }; let err = SharedHttpClient::new(config).unwrap_err(); assert!(matches!(err, HttpClientBuildError::CaBundleRead { .. })); } #[test] fn build_error_display_contains_path() { let err = HttpClientBuildError::CaBundleRead { path: PathBuf::from("/nonexistent/ca.pem"), source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"), }; let rendered = format!("{err}"); assert!(rendered.contains("/nonexistent/ca.pem")); } #[test] fn retry_after_capacity_constant_is_bounded() { let cap = DEFAULT_RETRY_AFTER_CAPACITY; assert!(cap > 0, "RetryAfterMiddleware storage must be non-zero"); assert!(cap <= 4096, "RetryAfterMiddleware storage must be bounded"); } #[test] fn no_env_vars_read_in_default_config() { let _ = SystemTime::now(); let config = HttpClientConfig::default(); assert!(config.ca_bundle.is_none()); } }