Review-mcp verification complete: all 12 checklist items pass (from_mcp/to_mcp conformance, ADR-037/041/014/023/034, feature gate isolation, GatewayDispatch concrete struct, test coverage 223+5). Applied cargo fmt across crate.
330 lines
11 KiB
Rust
330 lines
11 KiB
Rust
//! 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<usize>,
|
|
pub request_timeout: Option<Duration>,
|
|
pub retry_policy: ExponentialBackoff,
|
|
pub ca_bundle: Option<PathBuf>,
|
|
pub client_cert: Option<ClientCertConfig>,
|
|
}
|
|
|
|
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<ClientWithMiddleware>,
|
|
config: ArcSwap<HttpClientConfig>,
|
|
}
|
|
|
|
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<Self, HttpClientBuildError> {
|
|
let client = build_client(&config)?;
|
|
Ok(Self {
|
|
inner: ArcSwap::from_pointee(client),
|
|
config: ArcSwap::from_pointee(config),
|
|
})
|
|
}
|
|
|
|
pub fn client(&self) -> Arc<ClientWithMiddleware> {
|
|
self.inner.load_full()
|
|
}
|
|
|
|
pub fn config(&self) -> Arc<HttpClientConfig> {
|
|
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<ClientWithMiddleware, HttpClientBuildError> {
|
|
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<u8> {
|
|
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());
|
|
}
|
|
}
|